mdeck 2.3.0

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 (268) hide show
  1. package/CHANGELOG.md +259 -0
  2. package/LICENSE +21 -0
  3. package/README.md +156 -0
  4. package/assets/base.css +196 -0
  5. package/assets/extensions/layouts/chapter/extension.toml +26 -0
  6. package/assets/extensions/layouts/chapter/layout.jsx +21 -0
  7. package/assets/extensions/layouts/chapter/starter.md +8 -0
  8. package/assets/extensions/layouts/focus/extension.toml +8 -0
  9. package/assets/extensions/layouts/focus/layout.jsx +10 -0
  10. package/assets/extensions/layouts/focus/starter.md +4 -0
  11. package/assets/extensions/layouts/full-bleed-image/extension.toml +31 -0
  12. package/assets/extensions/layouts/full-bleed-image/layout.jsx +19 -0
  13. package/assets/extensions/layouts/full-bleed-image/starter.md +6 -0
  14. package/assets/extensions/layouts/generic/extension.toml +8 -0
  15. package/assets/extensions/layouts/generic/layout.jsx +8 -0
  16. package/assets/extensions/layouts/generic/starter.md +4 -0
  17. package/assets/extensions/layouts/image-text/extension.toml +25 -0
  18. package/assets/extensions/layouts/image-text/layout.jsx +17 -0
  19. package/assets/extensions/layouts/image-text/starter.md +6 -0
  20. package/assets/extensions/layouts/split/extension.toml +25 -0
  21. package/assets/extensions/layouts/split/layout.jsx +19 -0
  22. package/assets/extensions/layouts/split/starter.md +14 -0
  23. package/assets/extensions/layouts/title/extension.toml +26 -0
  24. package/assets/extensions/layouts/title/layout.jsx +14 -0
  25. package/assets/extensions/layouts/title/starter.md +5 -0
  26. package/assets/extensions/palettes/brand/extension.toml +29 -0
  27. package/assets/extensions/palettes/cobalt/extension.toml +27 -0
  28. package/assets/extensions/palettes/ember/extension.toml +27 -0
  29. package/assets/extensions/palettes/forest/extension.toml +27 -0
  30. package/assets/extensions/palettes/graphite/extension.toml +27 -0
  31. package/assets/extensions/palettes/lagoon/extension.toml +27 -0
  32. package/assets/extensions/palettes/neon/extension.toml +27 -0
  33. package/assets/extensions/palettes/nordic/extension.toml +27 -0
  34. package/assets/extensions/palettes/pastel/extension.toml +30 -0
  35. package/assets/extensions/palettes/phosphor/extension.toml +27 -0
  36. package/assets/extensions/palettes/swiss/extension.toml +27 -0
  37. package/assets/extensions/palettes/terra/extension.toml +27 -0
  38. package/assets/extensions/themes/academic/extension.toml +53 -0
  39. package/assets/extensions/themes/academic/styles.css +631 -0
  40. package/assets/extensions/themes/aurora/extension.toml +37 -0
  41. package/assets/extensions/themes/aurora/styles.css +644 -0
  42. package/assets/extensions/themes/duet/extension.toml +46 -0
  43. package/assets/extensions/themes/duet/styles.css +573 -0
  44. package/assets/extensions/themes/editorial/extension.toml +46 -0
  45. package/assets/extensions/themes/editorial/styles.css +574 -0
  46. package/assets/extensions/themes/fhnw/extension.toml +61 -0
  47. package/assets/extensions/themes/fhnw/styles.css +604 -0
  48. package/assets/extensions/themes/neue/extension.toml +46 -0
  49. package/assets/extensions/themes/neue/styles.css +489 -0
  50. package/assets/extensions/themes/sketch/extension.toml +48 -0
  51. package/assets/extensions/themes/sketch/styles.css +633 -0
  52. package/assets/extensions/themes/terminal/extension.toml +38 -0
  53. package/assets/extensions/themes/terminal/styles.css +550 -0
  54. package/assets/logo/avatar.png +0 -0
  55. package/assets/logo/logo-dark.svg +10 -0
  56. package/assets/logo/logo-light.svg +10 -0
  57. package/assets/logo/mark.svg +15 -0
  58. package/bin/mdeck.js +2 -0
  59. package/docs/README.md +8 -0
  60. package/docs/reference/authoring.md +795 -0
  61. package/docs/reference/claude-skill.md +96 -0
  62. package/docs/reference/extensions.md +250 -0
  63. package/docs/reference/layouts.md +153 -0
  64. package/docs/reference/palettes.md +106 -0
  65. package/docs/reference/structured-slides.md +120 -0
  66. package/docs/reference/themes.md +133 -0
  67. package/docs/site/assets/favicon.svg +15 -0
  68. package/docs/site/assets/github-logo.svg +1 -0
  69. package/docs/site/assets/site.css +162 -0
  70. package/docs/site/assets/site.js +123 -0
  71. package/docs/site/build.js +148 -0
  72. package/docs/site/content/appearance.md +203 -0
  73. package/docs/site/content/audience.md +178 -0
  74. package/docs/site/content/commands.md +153 -0
  75. package/docs/site/content/components.md +134 -0
  76. package/docs/site/content/drawing.md +72 -0
  77. package/docs/site/content/editing.md +59 -0
  78. package/docs/site/content/getting-started.md +103 -0
  79. package/docs/site/content/layouts.md +126 -0
  80. package/docs/site/content/media.md +107 -0
  81. package/docs/site/content/more-content.md +130 -0
  82. package/docs/site/content/presenting.md +93 -0
  83. package/docs/site/content/reusable-layouts.md +74 -0
  84. package/docs/site/content/sharing.md +88 -0
  85. package/docs/site/content/theme-authoring.md +144 -0
  86. package/docs/site/content/theme-repository.md +76 -0
  87. package/docs/site/content/troubleshooting.md +92 -0
  88. package/docs/site/content/views.md +56 -0
  89. package/docs/site/content/welcome.md +71 -0
  90. package/docs/site/content/writing.md +121 -0
  91. package/docs/site/examples/first-talk.md +88 -0
  92. package/docs/site/examples/notebook.svg +58 -0
  93. package/docs/site/images/themes/academic-dark.webp +0 -0
  94. package/docs/site/images/themes/academic-light.webp +0 -0
  95. package/docs/site/images/themes/academic.webp +0 -0
  96. package/docs/site/images/themes/aurora-dark.webp +0 -0
  97. package/docs/site/images/themes/aurora-light.webp +0 -0
  98. package/docs/site/images/themes/aurora.webp +0 -0
  99. package/docs/site/images/themes/duet-dark.webp +0 -0
  100. package/docs/site/images/themes/duet-light.webp +0 -0
  101. package/docs/site/images/themes/duet.webp +0 -0
  102. package/docs/site/images/themes/editorial-dark.webp +0 -0
  103. package/docs/site/images/themes/editorial-light.webp +0 -0
  104. package/docs/site/images/themes/editorial.webp +0 -0
  105. package/docs/site/images/themes/fhnw.webp +0 -0
  106. package/docs/site/images/themes/neue-dark.webp +0 -0
  107. package/docs/site/images/themes/neue-light.webp +0 -0
  108. package/docs/site/images/themes/neue.webp +0 -0
  109. package/docs/site/images/themes/sketch-dark.webp +0 -0
  110. package/docs/site/images/themes/sketch-light.webp +0 -0
  111. package/docs/site/images/themes/sketch.webp +0 -0
  112. package/docs/site/images/themes/terminal-dark.webp +0 -0
  113. package/docs/site/images/themes/terminal-light.webp +0 -0
  114. package/docs/site/images/themes/terminal.webp +0 -0
  115. package/docs/site/package.json +9 -0
  116. package/docs/site/pages.js +27 -0
  117. package/docs/site/server.js +58 -0
  118. package/examples/custom-layouts/extensions/comparison/extension.toml +35 -0
  119. package/examples/custom-layouts/extensions/comparison/layout.jsx +15 -0
  120. package/examples/custom-layouts/extensions/comparison/starter.md +17 -0
  121. package/examples/custom-layouts/extensions/comparison/styles.css +4 -0
  122. package/examples/custom-layouts/slides.md +60 -0
  123. package/examples/poll/slides.md +127 -0
  124. package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T08-36-47Z.json +19 -0
  125. package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T11-27-30Z.json +6 -0
  126. package/examples/python/slides.drawings.json +6 -0
  127. package/examples/python/slides.md +493 -0
  128. package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T08-08-30Z.json +10 -0
  129. package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T11-41-35Z.json +13 -0
  130. package/examples/showcase/img/image.jpg +0 -0
  131. package/examples/showcase/img/mdeck.svg +66 -0
  132. package/examples/showcase/slides.drawings.json +11 -0
  133. package/examples/showcase/slides.md +427 -0
  134. package/package.json +75 -0
  135. package/skills/write-slides/SKILL.md +144 -0
  136. package/src/build/check.js +49 -0
  137. package/src/build/chrome.js +130 -0
  138. package/src/build/components.js +44 -0
  139. package/src/build/config.js +61 -0
  140. package/src/build/drawings.js +14 -0
  141. package/src/build/editorPlugin.js +272 -0
  142. package/src/build/editorServer.js +36 -0
  143. package/src/build/fonts.js +84 -0
  144. package/src/build/homePlugin.js +208 -0
  145. package/src/build/inkPlugin.js +192 -0
  146. package/src/build/ownWrites.js +18 -0
  147. package/src/build/pairing.js +81 -0
  148. package/src/build/pdf.js +34 -0
  149. package/src/build/relayAccess.js +85 -0
  150. package/src/build/slidesPlugin.js +371 -0
  151. package/src/build/themeCheck.js +151 -0
  152. package/src/build/tunnelClient.js +184 -0
  153. package/src/cli/args.js +76 -0
  154. package/src/cli/index.js +758 -0
  155. package/src/cli/migrate.js +123 -0
  156. package/src/cli/skill.js +40 -0
  157. package/src/cli/themes.js +140 -0
  158. package/src/components/CodeBlock.jsx +168 -0
  159. package/src/components/ConnectedViews.jsx +25 -0
  160. package/src/components/Icon.jsx +8 -0
  161. package/src/components/Poll.jsx +67 -0
  162. package/src/components/QrCode.jsx +29 -0
  163. package/src/components/QrCodeTag.jsx +27 -0
  164. package/src/components/Question.jsx +28 -0
  165. package/src/components/Scale.jsx +47 -0
  166. package/src/components/VideoPlayer.jsx +108 -0
  167. package/src/components/WordCloud.jsx +106 -0
  168. package/src/components/activity.jsx +35 -0
  169. package/src/components/callout.css +78 -0
  170. package/src/components/code-block.css +123 -0
  171. package/src/components/columns.css +11 -0
  172. package/src/components/poll.css +81 -0
  173. package/src/components/video-player.css +14 -0
  174. package/src/core/devPath.js +4 -0
  175. package/src/core/editDeck.js +230 -0
  176. package/src/core/icons.js +69 -0
  177. package/src/core/ink.js +221 -0
  178. package/src/core/labels.js +144 -0
  179. package/src/core/outline.js +33 -0
  180. package/src/core/parseSlides.js +152 -0
  181. package/src/core/source.js +137 -0
  182. package/src/core/urls.js +34 -0
  183. package/src/core/validateDeck.js +139 -0
  184. package/src/editor/api.js +67 -0
  185. package/src/editor/components/App.jsx +75 -0
  186. package/src/editor/components/ConflictBanner.jsx +10 -0
  187. package/src/editor/components/DeckSettings.jsx +108 -0
  188. package/src/editor/components/DesignApp.jsx +252 -0
  189. package/src/editor/components/Diagnostics.jsx +10 -0
  190. package/src/editor/components/ExtensionEditor.jsx +66 -0
  191. package/src/editor/components/ExtensionForms.jsx +134 -0
  192. package/src/editor/components/ExtensionsPanel.jsx +114 -0
  193. package/src/editor/components/Field.jsx +24 -0
  194. package/src/editor/components/Inspector.jsx +36 -0
  195. package/src/editor/components/LayoutPicker.jsx +23 -0
  196. package/src/editor/components/LayoutSelect.jsx +25 -0
  197. package/src/editor/components/LookThumbnail.jsx +8 -0
  198. package/src/editor/components/MetaFields.jsx +24 -0
  199. package/src/editor/components/Outline.jsx +28 -0
  200. package/src/editor/components/PalettePicker.jsx +32 -0
  201. package/src/editor/components/Preview.jsx +63 -0
  202. package/src/editor/components/PropsForm.jsx +46 -0
  203. package/src/editor/components/RawSlideEditor.jsx +9 -0
  204. package/src/editor/components/RegionEditor.jsx +15 -0
  205. package/src/editor/components/Resizer.jsx +18 -0
  206. package/src/editor/designLink.js +19 -0
  207. package/src/editor/editor.css +139 -0
  208. package/src/editor/extensions.js +86 -0
  209. package/src/editor/keyboard.js +21 -0
  210. package/src/editor/model.js +38 -0
  211. package/src/editor/sampleDeck.js +165 -0
  212. package/src/editor/saveQueue.js +47 -0
  213. package/src/editor/schema.js +36 -0
  214. package/src/editor/state.js +92 -0
  215. package/src/editor/useDeckDocument.js +67 -0
  216. package/src/extensions/appearance.js +79 -0
  217. package/src/extensions/contrast.js +57 -0
  218. package/src/extensions/discover.js +126 -0
  219. package/src/extensions/manifest.js +209 -0
  220. package/src/extensions/packs.js +268 -0
  221. package/src/extensions/tokens.js +25 -0
  222. package/src/home/Home.jsx +362 -0
  223. package/src/home/home.css +148 -0
  224. package/src/layouts/layoutApi.jsx +205 -0
  225. package/src/layouts/layoutProps.js +30 -0
  226. package/src/layouts/renderSlide.jsx +17 -0
  227. package/src/live/answer/answer.css +34 -0
  228. package/src/live/answer/answer.html +14 -0
  229. package/src/live/answer/answer.js +130 -0
  230. package/src/live/client.js +290 -0
  231. package/src/live/code.js +19 -0
  232. package/src/live/follow.js +17 -0
  233. package/src/live/pairing.js +34 -0
  234. package/src/live/presence.js +33 -0
  235. package/src/live/roomTag.js +38 -0
  236. package/src/live/rooms.js +249 -0
  237. package/src/live/server.js +82 -0
  238. package/src/live/tunnel.js +180 -0
  239. package/src/paths.js +9 -0
  240. package/src/runtime/ReaderView.jsx +193 -0
  241. package/src/runtime/SlideErrorBoundary.jsx +28 -0
  242. package/src/runtime/chrome.jsx +55 -0
  243. package/src/runtime/deck-stage.js +1533 -0
  244. package/src/runtime/design.html +12 -0
  245. package/src/runtime/design.jsx +5 -0
  246. package/src/runtime/editor.html +12 -0
  247. package/src/runtime/editor.jsx +5 -0
  248. package/src/runtime/editorBridge.js +58 -0
  249. package/src/runtime/fit.js +149 -0
  250. package/src/runtime/home.html +13 -0
  251. package/src/runtime/home.jsx +5 -0
  252. package/src/runtime/index.html +15 -0
  253. package/src/runtime/ink/InkLayer.jsx +17 -0
  254. package/src/runtime/ink/Toolbar.jsx +145 -0
  255. package/src/runtime/ink/attach.js +69 -0
  256. package/src/runtime/ink/bus.js +126 -0
  257. package/src/runtime/ink/controller.js +132 -0
  258. package/src/runtime/ink/room.js +101 -0
  259. package/src/runtime/ink/select.js +124 -0
  260. package/src/runtime/ink/server.js +75 -0
  261. package/src/runtime/ink/store.js +55 -0
  262. package/src/runtime/ink/toolbar.css +79 -0
  263. package/src/runtime/main.jsx +863 -0
  264. package/src/runtime/markedSetup.js +147 -0
  265. package/src/runtime/reader.css +59 -0
  266. package/src/runtime/registry.jsx +26 -0
  267. package/src/runtime/syncOrder.js +32 -0
  268. package/src/runtime/themeLoader.js +68 -0
@@ -0,0 +1,19 @@
1
+ // The design page, where themes, palettes and layouts are made and changed.
2
+ // Both editor pages open it in the same named tab, so it is never open twice.
3
+ export const DESIGN_URL = '/design.html'
4
+ export const DESIGN_TAB = 'mdeck-design'
5
+
6
+ // `copy` starts a copy of a built-in extension to change; `open` opens one of
7
+ // the deck's own. Both are `kind:id`.
8
+ export function designUrl({ copy = null, open = null } = {}) {
9
+ const query = new URLSearchParams()
10
+ if (copy) query.set('copy', copy)
11
+ if (open) query.set('open', open)
12
+ return query.size ? `${DESIGN_URL}?${query}` : DESIGN_URL
13
+ }
14
+
15
+ // Reads `kind:id` back; null when it is not one.
16
+ export function parseTarget(value) {
17
+ const match = /^(theme|palette|layout):([a-z][a-z0-9-]*)$/.exec(value ?? '')
18
+ return match ? { kind: match[1], id: match[2] } : null
19
+ }
@@ -0,0 +1,139 @@
1
+ :root { color-scheme: dark; }
2
+ html, body { margin: 0; height: 100%; background: #111; color: #e6e6e6; font: 13px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
3
+ #editor-root, #design-root { height: 100%; }
4
+ .muted { color: #8e8e8e; }
5
+ button, select, input, textarea { font: inherit; }
6
+ a { color: #8ab4f8; }
7
+ .editor { display: grid; grid-template-rows: 44px 1fr; height: 100vh; }
8
+ .editor-topbar { display: flex; align-items: center; gap: 12px; padding: 0 14px; border-bottom: 1px solid #222; background: #161616; }
9
+ .editor-topbar h1 { font-size: 14px; font-weight: 600; margin: 0; color: #eee; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
10
+ .editor-topbar .spacer { flex: 1; }
11
+ .editor-topbar .tabs button { padding: 10px 12px; }
12
+ fieldset:disabled input, fieldset:disabled select, fieldset:disabled textarea, fieldset:disabled .btn { opacity: 0.6; cursor: not-allowed; }
13
+ .editor-status { color: #a8a8a8; font-size: 12px; }
14
+ .editor-status.is-error, .editor-status.is-conflict { color: #f28b82; }
15
+ .editor-status.is-saving, .editor-status.is-unsaved { color: #e3c36d; }
16
+ .editor-main { display: grid; grid-template-columns: 240px minmax(0, 1fr) 400px; min-height: 0; }
17
+ .editor-panel { min-height: 0; overflow: hidden auto; border-right: 1px solid #222; position: relative; }
18
+ .resizer { position: sticky; top: 0; float: left; width: 6px; height: 100vh; margin-right: -6px; cursor: col-resize; z-index: 3; }
19
+ .resizer:hover { background: rgba(138, 180, 248, 0.25); }
20
+ .editor-panel:last-child { border-right: 0; }
21
+ .editor-panel-head { display: flex; align-items: center; gap: 6px; padding: 10px 12px; border-bottom: 1px solid #222; position: sticky; top: 0; background: #111; z-index: 1; }
22
+ .editor-panel-head span { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: #a0a0a0; flex: 1; }
23
+ .btn { appearance: none; display: inline-flex; align-items: center; justify-content: center; gap: 4px; padding: 5px 10px; border-radius: 5px; border: 1px solid #3a3a3a; background: #222; color: #e0e0e0; cursor: pointer; line-height: 1.4; }
24
+ .btn:hover { color: #fff; border-color: #5a5a5a; }
25
+ .btn:disabled { opacity: 0.4; cursor: default; }
26
+ .btn.is-primary { background: #27436a; border-color: #3d6094; color: #e8f0ff; }
27
+ .btn.is-small { padding: 2px 7px; font-size: 12px; }
28
+ .btn.is-icon { padding: 3px 5px; }
29
+ .outline-list { list-style: none; margin: 0; padding: 6px; }
30
+ .outline-item { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; gap: 8px; align-items: center; padding: 7px 8px; border-radius: 6px; cursor: pointer; color: #d6d6d6; }
31
+ .outline-item:hover { background: #1e1e1e; }
32
+ .outline-item.is-selected { background: #23384f; color: #ffffff; }
33
+ .outline-item .num { color: #8a8a8a; font-variant-numeric: tabular-nums; }
34
+ .outline-item > span { min-width: 0; }
35
+ .outline-item .title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
36
+ .outline-item .layout { font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; color: #9a9a9a; }
37
+ .outline-item .dot { width: 8px; height: 8px; border-radius: 50%; background: #f28b82; }
38
+ .outline-item.has-thumb { grid-template-columns: minmax(0, 1fr) !important; grid-template-rows: auto auto; gap: 6px; }
39
+ .thumb { aspect-ratio: 16 / 9; overflow: hidden; border-radius: 4px; border: 1px solid #2a2a2a; background: #000; pointer-events: none; }
40
+ .thumb iframe { width: 100%; height: 100%; border: 0; display: block; }
41
+ .outline-tools { display: flex; gap: 4px; padding: 6px 12px; border-top: 1px solid #222; position: sticky; bottom: 0; background: #111; }
42
+ .preview { display: flex; flex-direction: column; min-height: 0; background: #0b0b0b; }
43
+ .preview-frame { flex: 1; display: flex; align-items: center; justify-content: center; padding: 16px; min-height: 0; }
44
+ .preview-frame iframe { border: 0; background: #000; box-shadow: 0 8px 40px rgba(0,0,0,0.6); width: 100%; max-height: 100%; }
45
+ .preview-tools { display: flex; align-items: center; gap: 6px; padding: 8px 14px; border-top: 1px solid #222; color: #b0b0b0; }
46
+ .preview-tools .spacer { flex: 1; }
47
+ .tabs { display: flex; gap: 2px; padding: 8px 12px 0; border-bottom: 1px solid #222; position: sticky; top: 0; background: #111; z-index: 1; }
48
+ .tabs button { appearance: none; background: none; border: 0; border-bottom: 2px solid transparent; color: #b4b4b4; padding: 6px 10px; cursor: pointer; }
49
+ .tabs button.is-active { color: #fff; border-bottom-color: #8ab4f8; }
50
+ .form { padding: 12px; display: flex; flex-direction: column; gap: 14px; }
51
+ .field { display: flex; flex-direction: column; gap: 4px; }
52
+ .field label { color: #b8b8b8; font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; display: flex; gap: 6px; align-items: baseline; }
53
+ .field label .req { color: #e3c36d; text-transform: none; letter-spacing: 0; }
54
+ .field label .hint { color: #8e8e8e; text-transform: none; letter-spacing: 0; font-size: 12px; }
55
+ .field .row { display: flex; gap: 6px; align-items: center; }
56
+ .field .row > * { flex: 1; min-width: 0; }
57
+ .field .row > .btn { flex: 0 0 auto; }
58
+ .field .error { color: #f28b82; font-size: 12px; }
59
+ .field small { color: #8e8e8e; }
60
+ input[type="text"], input[type="number"], select, textarea { width: 100%; box-sizing: border-box; background: #1b1b1b; color: #f2f2f2; border: 1px solid #3a3a3a; border-radius: 5px; padding: 6px 8px; }
61
+ input[type="text"]:focus, input[type="number"]:focus, select:focus, textarea:focus { outline: none; border-color: #4a6a8a; }
62
+ input[type="color"] { width: 32px; height: 28px; padding: 2px; border: 1px solid #3a3a3a; border-radius: 5px; background: #1b1b1b; cursor: pointer; }
63
+ textarea { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13.5px; line-height: 1.55; resize: vertical; min-height: 64px; }
64
+ textarea.is-tall { min-height: calc(100vh - 220px); }
65
+ .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
66
+ .section-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: #c0c0c0; margin: 4px 0 -6px; }
67
+ details.more summary { cursor: pointer; color: #b4b4b4; }
68
+ details.more > .form { padding: 10px 0 0; }
69
+ .diagnostics { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
70
+ .diagnostics li { font-size: 12px; padding: 6px 8px; border-radius: 5px; background: #1a1414; color: #f28b82; }
71
+ .diagnostics li.is-warning { background: #1a1810; color: #e3c36d; }
72
+ .diagnostics li code { opacity: 0.7; margin-right: 6px; }
73
+ .banner { display: flex; gap: 10px; align-items: center; padding: 8px 14px; background: #3a2a1a; color: #f3d9b1; border-bottom: 1px solid #5a3a1a; }
74
+ .banner .spacer { flex: 1; }
75
+ .modal-scrim { position: fixed; inset: 0; background: rgba(0,0,0,0.75); display: flex; align-items: center; justify-content: center; z-index: 100; }
76
+ .modal { background: #161616; border: 1px solid #2a2a2a; border-radius: 10px; padding: 18px; width: min(90vw, 560px); max-height: 80vh; overflow: auto; }
77
+ .modal h2 { margin: 0 0 12px; font-size: 14px; color: #eee; }
78
+ .picker { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
79
+ .picker button { width: 100%; text-align: left; display: flex; flex-direction: column; gap: 2px; }
80
+ .picker button small { color: #b0b0b0; }
81
+ .swatches { display: inline-flex; gap: 2px; }
82
+ .swatches i { width: 10px; height: 10px; border-radius: 2px; border: 1px solid rgba(255,255,255,0.08); }
83
+ .empty { padding: 40px 20px; color: #a0a0a0; text-align: center; }
84
+ .loading { display: flex; align-items: center; justify-content: center; height: 100vh; color: #b0b0b0; }
85
+ /* The deck's palette: a list showing every palette's colours, and light or dark. */
86
+ .palette-picker { position: relative; }
87
+ .palette-current, .palette-list button { appearance: none; display: flex; align-items: center; gap: 8px; width: 100%; box-sizing: border-box; padding: 6px 8px; border: 1px solid #3a3a3a; border-radius: 5px; background: #1b1b1b; color: #f2f2f2; cursor: pointer; text-align: left; }
88
+ .palette-current:hover, .palette-current[aria-expanded="true"] { border-color: #4a6a8a; }
89
+ .palette-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
90
+ .palette-default { color: #8e8e8e; }
91
+ .palette-list { position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 10; max-height: 360px; overflow: auto; padding: 4px; border: 1px solid #3a3a3a; border-radius: 6px; background: #1b1b1b; box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5); }
92
+ .palette-list button { border-color: transparent; background: none; color: #c8c8c8; }
93
+ .palette-list button:hover { background: #262626; color: #fff; }
94
+ .palette-list button.is-active { background: #27436a; color: #fff; }
95
+ .appearance-toggle { display: flex; gap: 6px; }
96
+ .appearance-toggle .btn { flex: 1; }
97
+ .appearance-toggle .btn.is-active { background: #27436a; border-color: #3d6094; color: #fff; }
98
+ .note { margin: 0; padding: 8px 10px; border-left: 3px solid #4a6a8a; background: #1a2230; color: #c8d4e4; font-size: 12px; border-radius: 0 4px 4px 0; }
99
+ .extensions-tabs { padding: 0 10px 0 4px; align-items: stretch; }
100
+ .extensions-tabs .tabs { flex: 1; padding: 0; border: 0; position: static; background: none; }
101
+ .extensions-tabs .tabs button { padding: 12px 8px 10px; }
102
+ .extensions-tabs > .btn { align-self: center; }
103
+ .preview-bar { display: flex; align-items: center; gap: 8px; padding: 8px 14px; border-bottom: 1px solid #222; color: #e0e0e0; }
104
+ .preview-bar .spacer { flex: 1; }
105
+ .preview-bar .muted { color: #8e8e8e; }
106
+ .preview-choice { display: inline-flex; align-items: center; gap: 6px; color: #a8a8a8; font-size: 12px; }
107
+ .preview-choice select { max-width: 160px; }
108
+ .appearance-toggle.is-compact { gap: 2px; }
109
+ .appearance-toggle.is-compact .btn { flex: 0 0 auto; }
110
+ .segmented { display: inline-flex; border: 1px solid #3a3a3a; border-radius: 5px; overflow: hidden; }
111
+ .segmented button { appearance: none; border: 0; background: #1b1b1b; color: #c8c8c8; padding: 3px 10px; font-size: 12px; cursor: pointer; }
112
+ .segmented button + button { border-left: 1px solid #3a3a3a; }
113
+ .segmented button.is-active { background: #27436a; color: #fff; }
114
+ /* The deck's theme, chosen from pictures of its first slide. */
115
+ .theme-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 8px; }
116
+ .theme-choice { appearance: none; display: flex; flex-direction: column; gap: 4px; padding: 4px; border: 1px solid #2a2a2a; border-radius: 6px; background: #161616; color: #d6d6d6; cursor: pointer; text-align: left; font-size: 12px; }
117
+ .theme-choice:hover { border-color: #4a6a8a; }
118
+ .theme-choice.is-active { border-color: #60a5fa; background: #1d2b3c; color: #fff; }
119
+ .theme-choice-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0 2px; }
120
+ /* The design page: the deck's own extensions first, then built-in ones. */
121
+ .panel-section-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px 14px 0; }
122
+ .panel-section-head .section-title { margin: 0; }
123
+ .online-pack .thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
124
+ .online-pack { cursor: default; }
125
+ .online-meta { font-size: 11px; color: #9a9a9a; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
126
+ .online-error { color: #f28b82; margin: 6px 0 0; }
127
+ .hint-text { margin: 0; color: #9a9a9a; font-size: 12px; }
128
+ .panel-hint { margin: 6px 14px 4px; color: #9a9a9a; font-size: 12px; }
129
+ .design-usage { display: flex; align-items: center; gap: 8px; padding: 8px 14px; border-bottom: 1px solid #222; background: #161b22; font-size: 12px; }
130
+ .design-usage > span { flex: 1; display: inline-flex; align-items: center; gap: 6px; }
131
+ .palette-checks { display: grid; gap: 2px; max-height: 220px; overflow: auto; padding: 4px; border: 1px solid #2a2a2a; border-radius: 5px; }
132
+ .field .palette-check { display: flex; align-items: center; gap: 8px; padding: 3px 4px; border-radius: 4px; cursor: pointer; text-transform: none; letter-spacing: normal; font-size: 13px; color: #d6d6d6; }
133
+ .field .palette-check:hover { background: #1e1e1e; }
134
+ .builtin-title { margin: 0; font-size: 16px; color: #f0f0f0; }
135
+ .builtin-title .muted { font-size: 12px; font-weight: 400; margin-left: 6px; }
136
+ .builtin-description { margin: 0; color: #c0c0c0; }
137
+ .builtin-source summary, .advanced summary { cursor: pointer; color: #a8a8a8; }
138
+ .builtin-source pre { margin: 8px 0 0; padding: 10px; max-height: 360px; overflow: auto; background: #161616; border: 1px solid #2a2a2a; border-radius: 5px; font-size: 11px; white-space: pre-wrap; }
139
+ .advanced[open] summary { margin-bottom: 8px; }
@@ -0,0 +1,86 @@
1
+ // Extension editing model: the files of an extension folder are the source of
2
+ // truth; forms read a model out of extension.toml and write it back as TOML.
3
+ import { parse, stringify } from 'smol-toml'
4
+
5
+ export const MANIFEST = 'extension.toml'
6
+ export const ID_RE = /^[a-z][a-z0-9-]*$/
7
+ export { COLOR_ROLES as CORE_TOKENS } from '../extensions/tokens.js'
8
+ // Readable colours a new palette starts from.
9
+ const STARTER_COLORS = {
10
+ light: { '--bg': '#ffffff', '--surface': '#f2f2f2', '--ink': '#111111', '--ink-soft': '#333333', '--muted': '#666666', '--rule': '#dadada', '--accent': '#0b6bcb', '--accent-2': '#c2410c', '--on-accent': '#ffffff' },
11
+ dark: { '--bg': '#111111', '--surface': '#1e1e1e', '--ink': '#f2f2f2', '--ink-soft': '#cfcfcf', '--muted': '#8f8f8f', '--rule': '#2e2e2e', '--accent': '#6aaeff', '--accent-2': '#fb923c', '--on-accent': '#111111' },
12
+ }
13
+
14
+ const entries = table => Object.entries(table ?? {}).map(([name, value]) => ({ name, ...(value && typeof value === 'object' && !Array.isArray(value) ? value : { value }) }))
15
+ const table = (list, pick) => Object.fromEntries(list.filter(item => item.name).map(item => [item.name, pick(item)]))
16
+ const clean = object => Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined && value !== '' && value !== null && !(Array.isArray(value) && !value.length)))
17
+
18
+ export function parseManifest(text) {
19
+ try { return { raw: parse(text) } } catch (error) { return { error: error.message.replace(/^Invalid TOML document: /, ''), line: error.line, column: error.column } }
20
+ }
21
+
22
+ // Raw TOML object → editable model. Unknown fields are kept in `extra`.
23
+ export function toModel(raw) {
24
+ // `starter`, `source` and `file` come from registry listings, not manifests.
25
+ const { schema, kind, id, title, description, theme, light, dark, palette, palettes, appearance, fonts, files, tokens, params, frame, regions, properties, starter, source, file, ...extra } = raw
26
+ const model = { kind, id: id ?? '', title: title ?? '', description: description ?? '', extra, files: files ?? {} }
27
+ // A palette: its light and dark colours, and the theme it belongs to, if any.
28
+ if (kind === 'palette') return { ...model, theme: theme ?? '', light: entries(light), dark: entries(dark) }
29
+ if (kind === 'theme') return { ...model, palette: palette ?? '', palettes: palettes ?? [], appearance: appearance ?? 'light', fonts: fonts ?? [], tokens: entries(tokens), params: entries(params) }
30
+ if (kind === 'layout') return { ...model, frame: frame ?? 'standard', regions: entries(regions), properties: entries(properties).map(property => ({ ...property, enum: property.enum ?? [], items: property.items ?? null })) }
31
+ return model
32
+ }
33
+
34
+ export function toToml(model) {
35
+ const base = { schema: 1, kind: model.kind, id: model.id, title: model.title, ...clean({ description: model.description }) }
36
+ const tokens = table(model.tokens ?? [], token => token.value ?? '')
37
+ let raw
38
+ if (model.kind === 'palette') raw = { ...base, ...clean({ theme: model.theme }), ...model.extra, light: table(model.light ?? [], token => token.value ?? ''), dark: table(model.dark ?? [], token => token.value ?? '') }
39
+ else if (model.kind === 'theme') raw = {
40
+ ...base, palette: model.palette, ...clean({ palettes: model.palettes }), ...(model.appearance === 'dark' ? { appearance: 'dark' } : {}), fonts: model.fonts ?? [], ...model.extra,
41
+ files: { styles: 'styles.css', ...(model.files ?? {}) }, tokens,
42
+ params: table(model.params ?? [], param => clean({ token: param.token, title: param.title, description: param.description })),
43
+ }
44
+ else if (model.kind === 'layout') raw = {
45
+ ...base, ...(model.frame && model.frame !== 'standard' ? { frame: model.frame } : {}), ...model.extra, ...(Object.keys(model.files ?? {}).length ? { files: model.files } : {}),
46
+ regions: table(model.regions ?? [], region => clean({ description: region.description, required: region.required || undefined })),
47
+ properties: table(model.properties ?? [], property => propertySchema(property)),
48
+ }
49
+ else raw = { ...base, ...model.extra }
50
+ if (raw.params && !Object.keys(raw.params).length) delete raw.params
51
+ if (raw.properties && !Object.keys(raw.properties).length) delete raw.properties
52
+ return stringify(raw) + '\n'
53
+ }
54
+
55
+ function propertySchema(property) {
56
+ const schema = clean({ type: property.type ?? 'string', title: property.title, description: property.description, required: property.required || undefined,
57
+ enum: property.enum?.length ? property.enum : undefined, minimum: property.minimum, maximum: property.maximum, minItems: property.minItems, maxItems: property.maxItems })
58
+ if (property.default !== undefined && property.default !== null) schema.default = property.default
59
+ if (property.items && property.type === 'array') schema.items = clean({ type: property.items.type ?? 'string', minimum: property.items.minimum, maximum: property.items.maximum, enum: property.items.enum?.length ? property.items.enum : undefined })
60
+ return schema
61
+ }
62
+
63
+ // Manifest as the runtime expects it, for live previews of unsaved work.
64
+ export function toRuntimeManifest(model) {
65
+ const tokens = table(model.tokens ?? [], token => token.value ?? '')
66
+ const shared = { id: model.id, title: model.title, description: model.description ?? '' }
67
+ if (model.kind === 'palette') return { ...shared, ...(model.theme ? { theme: model.theme } : {}), light: table(model.light ?? [], token => token.value ?? ''), dark: table(model.dark ?? [], token => token.value ?? '') }
68
+ if (model.kind === 'theme') return {
69
+ ...shared, palette: model.palette, ...(model.palettes?.length ? { palettes: model.palettes } : {}), appearance: model.appearance ?? 'light', fonts: model.fonts ?? [], tokens,
70
+ params: table(model.params ?? [], param => ({ token: param.token, default: tokens[param.token] ?? '', ...(param.title ? { title: param.title } : {}) })),
71
+ }
72
+ return shared
73
+ }
74
+
75
+ // Starting points for new extensions. `from` is a registry manifest to copy;
76
+ // a theme is always a copy, a palette may start from readable plain colours.
77
+ export function starterFiles(kind, id, title, from = null, fromFiles = {}) {
78
+ if (kind === 'palette') {
79
+ const light = from?.light ? entries(from.light) : entries(STARTER_COLORS.light)
80
+ const dark = from?.dark ? entries(from.dark) : entries(STARTER_COLORS.dark)
81
+ return { [MANIFEST]: toToml({ kind, id, title, description: '', theme: '', light, dark, extra: {} }) }
82
+ }
83
+ if (kind !== 'theme' || !from) throw new Error('A new theme starts as a copy of another one')
84
+ const params = from.params && Object.fromEntries(Object.entries(from.params).map(([name, param]) => [name, { token: param.token, title: param.title, description: param.description }]))
85
+ return { [MANIFEST]: toToml({ ...toModel({ ...from, kind, params }), id, title, files: {} }), 'styles.css': fromFiles['styles.css'] ?? '' }
86
+ }
@@ -0,0 +1,21 @@
1
+ const EDITABLE = 'input, textarea, select, [contenteditable]'
2
+
3
+ // Undo and redo work everywhere (textarea values derive from the source, so
4
+ // native undo would fight the model). Navigation and structure shortcuts stay
5
+ // out of text fields.
6
+ export function installShortcuts(handlers, target = window) {
7
+ const onKey = event => {
8
+ const mod = event.metaKey || event.ctrlKey
9
+ const key = event.key.toLowerCase()
10
+ if (mod && key === 'z') { event.preventDefault(); return event.shiftKey ? handlers.redo?.() : handlers.undo?.() }
11
+ if (mod && key === 'y') { event.preventDefault(); return handlers.redo?.() }
12
+ if (event.target?.closest?.(EDITABLE)) return
13
+ if (mod && key === 'enter') { event.preventDefault(); return handlers.add?.() }
14
+ if (mod && key === 'backspace') { event.preventDefault(); return handlers.remove?.() }
15
+ if (mod) return
16
+ if (key === 'arrowup' || key === 'arrowleft' || key === 'pageup') { event.preventDefault(); return handlers.prev?.() }
17
+ if (key === 'arrowdown' || key === 'arrowright' || key === 'pagedown') { event.preventDefault(); return handlers.next?.() }
18
+ }
19
+ target.addEventListener('keydown', onKey)
20
+ return () => target.removeEventListener('keydown', onKey)
21
+ }
@@ -0,0 +1,38 @@
1
+ // Read-only helpers that derive editor views from the parsed deck and the
2
+ // extension manifests.
3
+ import { marked } from 'marked'
4
+ export { slideTitle } from '../core/outline.js'
5
+
6
+ // Body first, then the manifest's regions in order, then explicit regions the
7
+ // manifest does not know about.
8
+ export function regionsFor(slide, manifest) {
9
+ const declared = manifest?.regions ?? { body: {} }
10
+ const names = ['body', ...Object.keys(declared).filter(name => name !== 'body')]
11
+ const known = new Set(names)
12
+ const list = names.map(name => ({
13
+ name, description: declared[name]?.description ?? '', required: Boolean(declared[name]?.required),
14
+ present: Boolean(slide.regions[name]), content: slide.regions[name]?.content ?? '', unknown: false,
15
+ }))
16
+ for (const name of Object.keys(slide.regions)) {
17
+ if (!known.has(name)) list.push({ name, description: '', required: false, present: true, content: slide.regions[name].content, unknown: true })
18
+ }
19
+ return list
20
+ }
21
+
22
+ // Where an authored property value lives: under props, as a legacy top-level
23
+ // key (built-in layouts accept image:, alt:, fit:, position:, overlay:), or nowhere.
24
+ export function propSource(authoredMeta = {}, key) {
25
+ if (authoredMeta.props && Object.hasOwn(authoredMeta.props, key)) return 'props'
26
+ if (Object.hasOwn(authoredMeta, key) && key !== 'props') return 'legacy'
27
+ return null
28
+ }
29
+
30
+ export function slideDiagnostics(diagnostics, slide) {
31
+ return diagnostics.filter(d => d.offset >= slide.source.start && d.offset < Math.max(slide.source.end, slide.source.start + 1))
32
+ }
33
+
34
+ export function deckDiagnostics(diagnostics, deck) {
35
+ return diagnostics.filter(d => !deck.slides.some(slide => d.offset >= slide.source.start && d.offset < Math.max(slide.source.end, slide.source.start + 1)))
36
+ }
37
+
38
+ export { marked }
@@ -0,0 +1,165 @@
1
+ // The sample deck: every built-in layout and kind of content, once, in
2
+ // ordinary amounts. The design page previews themes and palettes on it, and
3
+ // `npm run test:themes` checks every theme against it.
4
+
5
+ export const SAMPLE_PICTURE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300"><rect width="400" height="300" fill="#6b7f99"/><circle cx="140" cy="150" r="80" fill="#e0c068"/><rect x="220" y="80" width="120" height="140" fill="#2d3e50"/></svg>'
6
+
7
+ // `picture` is where the slides find the picture: a file beside the deck, or
8
+ // a data URL where there is no such file.
9
+ export function sampleDeck(picture = './picture.svg') {
10
+ return `---
11
+ meta:
12
+ title: "Sample talk"
13
+ author: "A. Author"
14
+ organization: "Institute"
15
+ date: "2026-10-06"
16
+ ---
17
+
18
+ ---
19
+ layout: title
20
+ ---
21
+ # A talk about *trying* themes.
22
+ ## Every layout and every kind of content, once.
23
+
24
+ ---
25
+ layout: title
26
+ image: ./picture.svg
27
+ alt: "Shapes"
28
+ ---
29
+ # A title with a picture.
30
+ ## And a subtitle beside it.
31
+
32
+ ---
33
+ layout: chapter
34
+ number: 2
35
+ part: Methods
36
+ ---
37
+ # How it was *measured*
38
+
39
+ One sentence about what this chapter covers.
40
+
41
+ ---
42
+ layout: chapter
43
+ number: 3
44
+ image: ./picture.svg
45
+ alt: "Shapes"
46
+ ---
47
+ # A chapter with a picture
48
+
49
+ ---
50
+ # Lists, emphasis and code
51
+
52
+ - A point with *emphasis* and **strong** words
53
+ - A second point with \`inline code\`
54
+ - A nested point
55
+ - Another nested point
56
+
57
+ \`\`\`js
58
+ const total = values.reduce((sum, value) => sum + value, 0)
59
+ \`\`\`
60
+
61
+ ---
62
+ # Numbers in a table
63
+
64
+ | Group | Mean | Spread |
65
+ |---|---|---|
66
+ | Control | 4.2 | 0.8 |
67
+ | Treatment | 5.1 | 0.6 |
68
+ | Follow-up | 5.0 | 0.7 |
69
+
70
+ The difference is $\\Delta = 0.9$, with a footnote.[^1]
71
+
72
+ [^1]: A source for the claim.
73
+
74
+ ---
75
+ # Callouts
76
+
77
+ ::: tip
78
+ A tip in one sentence.
79
+ :::
80
+
81
+ ::: warning
82
+ A warning in one sentence.
83
+ :::
84
+
85
+ ::: note Your own title
86
+ A note with a title of its own.
87
+ :::
88
+
89
+ ---
90
+ # Blocks for lectures
91
+
92
+ ::: definition Definition 1 (Group)
93
+ A set with an associative operation, a neutral element and inverses.
94
+ :::
95
+
96
+ ::: theorem Theorem 2 (Lagrange)
97
+ The order of a subgroup divides the order of the group.
98
+ :::
99
+
100
+ ::: proof
101
+ The cosets partition the group into parts of equal size.
102
+ :::
103
+
104
+ ---
105
+ # Two columns
106
+
107
+ :::columns
108
+ Some text on the left, with a formula:
109
+
110
+ $$
111
+ \\int_0^1 x^2 \\, dx = \\frac{1}{3}
112
+ $$
113
+
114
+ +++
115
+
116
+ 1. First step
117
+ 2. Second step
118
+ 3. Third step
119
+ :::
120
+
121
+ ---
122
+ layout: focus
123
+ eyebrow: Key point
124
+ attribution: "Someone wise"
125
+ ---
126
+ # A statement that takes a line or two on the slide.
127
+
128
+ ---
129
+ layout: image-text
130
+ image: ./picture.svg
131
+ alt: "Shapes"
132
+ ---
133
+ # A picture beside words
134
+
135
+ Two sentences of text beside the picture. They explain what it shows.
136
+
137
+ ---
138
+ layout: split
139
+ ---
140
+ # A split slide
141
+ :::slot left
142
+ \`\`\`python
143
+ def mean(values):
144
+ return sum(values) / len(values)
145
+ \`\`\`
146
+ :::
147
+ :::slot right
148
+ - What the code does
149
+ - Why it matters
150
+ :::
151
+
152
+ ---
153
+ layout: full-bleed-image
154
+ image: ./picture.svg
155
+ alt: "Shapes"
156
+ overlay: true
157
+ ---
158
+ # A picture that fills the slide.
159
+ `.replaceAll('./picture.svg', picture)
160
+ }
161
+
162
+ // The picture as a data URL, for a sample deck that has no folder of its own.
163
+ export function sampleDataUrl() {
164
+ return `data:image/svg+xml,${encodeURIComponent(SAMPLE_PICTURE)}`
165
+ }
@@ -0,0 +1,47 @@
1
+ // Debounced, serialized autosave. The queue owns the base hash: every
2
+ // successful save advances it, a conflict pauses saving until the UI decides.
3
+ export function createSaveQueue({ save, delay = 400, setTimeout = globalThis.setTimeout.bind(globalThis), clearTimeout = globalThis.clearTimeout.bind(globalThis), onSaving = () => {}, onSaved = () => {}, onConflict = () => {}, onError = () => {} }) {
4
+ let base = null
5
+ let latest = null
6
+ let timer = null
7
+ let inflight = null
8
+ let paused = false
9
+
10
+ function push(source) {
11
+ latest = source
12
+ if (paused) return
13
+ clearTimeout(timer)
14
+ timer = setTimeout(flush, delay)
15
+ }
16
+
17
+ function flush() {
18
+ clearTimeout(timer)
19
+ timer = null
20
+ if (inflight) return inflight
21
+ if (latest == null || paused || base == null) return Promise.resolve()
22
+ const source = latest
23
+ latest = null
24
+ onSaving()
25
+ inflight = Promise.resolve()
26
+ .then(() => save(source, base))
27
+ .then(result => { base = result.hash; onSaved({ hash: result.hash, source }) })
28
+ .catch(error => {
29
+ if (error?.conflict) { paused = true; latest = latest ?? source; onConflict(error) }
30
+ else { latest = latest ?? source; onError(error) }
31
+ })
32
+ .finally(() => { inflight = null; if (latest != null && !paused) push(latest) })
33
+ return inflight
34
+ }
35
+
36
+ return {
37
+ push,
38
+ flush,
39
+ setBase(hash) { base = hash },
40
+ base: () => base,
41
+ // After a conflict: overwrite keeps the local text and saves it over the disk version.
42
+ overwrite(hash) { base = hash; paused = false; return flush() },
43
+ // Reload drops the local text in favour of the disk version.
44
+ reload(hash) { base = hash; paused = false; latest = null },
45
+ pending: () => Boolean(latest != null || inflight || timer),
46
+ }
47
+ }
@@ -0,0 +1,36 @@
1
+ // Maps layout property schemas to form controls and back.
2
+ import yaml from 'js-yaml'
3
+
4
+ export function fieldKind(schema = {}) {
5
+ if (Array.isArray(schema.enum) && schema.enum.length) return 'select'
6
+ if (schema.type === 'boolean') return 'checkbox'
7
+ if (schema.type === 'number' || schema.type === 'integer') return 'number'
8
+ if (schema.type === 'array' || schema.type === 'object') return 'yaml'
9
+ return 'text'
10
+ }
11
+
12
+ // Returns { value } or { error } for a control's raw input.
13
+ export function parseFieldValue(kind, raw, schema = {}) {
14
+ if (kind === 'checkbox') return { value: Boolean(raw) }
15
+ if (kind === 'number') {
16
+ if (String(raw).trim() === '') return { value: undefined }
17
+ const value = Number(raw)
18
+ if (!Number.isFinite(value)) return { error: 'Enter a number' }
19
+ if (schema.type === 'integer' && !Number.isInteger(value)) return { error: 'Enter a whole number' }
20
+ return { value }
21
+ }
22
+ if (kind === 'yaml') {
23
+ if (String(raw).trim() === '') return { value: undefined }
24
+ try {
25
+ const value = yaml.load(raw)
26
+ return { value }
27
+ } catch (error) { return { error: error.reason ?? error.message } }
28
+ }
29
+ return { value: String(raw) }
30
+ }
31
+
32
+ export function formatFieldValue(kind, value) {
33
+ if (value === undefined || value === null) return ''
34
+ if (kind === 'yaml') return yaml.dump(value, { flowLevel: Array.isArray(value) ? 0 : 1, lineWidth: -1, noRefs: true }).replace(/\n$/, '')
35
+ return String(value)
36
+ }
@@ -0,0 +1,92 @@
1
+ // The editor's single source of truth is the deck source string. Every change
2
+ // goes through `edit`, which applies a pure helper from src/core/editDeck.js,
3
+ // reparses, revalidates and records undo history.
4
+ import { parseSlides } from '../core/parseSlides.js'
5
+ import { validateDeck } from '../core/validateDeck.js'
6
+
7
+ const HISTORY_LIMIT = 200
8
+ const COALESCE_MS = 1000
9
+
10
+ function byId(list = []) { return Object.fromEntries(list.map(item => [item.id, item])) }
11
+
12
+ export function derive(source, manifests) {
13
+ const deck = parseSlides(source)
14
+ const diagnostics = validateDeck(deck, { layouts: manifests.layouts, themes: manifests.themes, palettes: manifests.palettes })
15
+ return { deck, diagnostics }
16
+ }
17
+
18
+ export const initialState = {
19
+ loaded: false, designOnly: false, path: '', name: '', source: '', hash: null, savedSource: '',
20
+ deck: null, diagnostics: [], registry: null, manifests: { layouts: {}, themes: {}, palettes: {} }, warnings: [],
21
+ selectedIndex: 0, tab: 'slide', inspectorMode: 'form',
22
+ history: { past: [], future: [] },
23
+ status: 'saved', conflict: null, error: null, preview: { error: null, slideCount: 0 },
24
+ }
25
+
26
+ const clamp = (index, deck) => Math.max(0, Math.min(index, deck.slides.length - 1))
27
+
28
+ export function selectedSlide(state) { return state.deck?.slides[state.selectedIndex] ?? null }
29
+
30
+ export function reduce(state, action) {
31
+ switch (action.type) {
32
+ case 'load': {
33
+ const manifests = { layouts: byId(action.registry?.layouts), themes: byId(action.registry?.themes), palettes: byId(action.registry?.palettes) }
34
+ const { deck, diagnostics } = derive(action.source, manifests)
35
+ return { ...state, loaded: true, designOnly: Boolean(action.designOnly), path: action.path, name: action.name, source: action.source, savedSource: action.source, hash: action.hash, deck, diagnostics,
36
+ registry: action.registry ?? null, manifests, warnings: action.warnings ?? [], selectedIndex: clamp(state.selectedIndex, deck), history: { past: [], future: [] }, status: 'saved', conflict: null, error: null }
37
+ }
38
+ case 'edit': {
39
+ const result = action.apply(state.deck)
40
+ const source = typeof result === 'string' ? result : result.source
41
+ const index = typeof result === 'string' ? action.selectIndex ?? state.selectedIndex : result.index
42
+ if (source === state.source) return index === state.selectedIndex ? state : { ...state, selectedIndex: clamp(index, state.deck) }
43
+ const now = action.now ?? Date.now()
44
+ const last = state.history.past.at(-1)
45
+ const coalesce = action.group && last?.group === action.group && now - last.time < COALESCE_MS
46
+ const entry = { source: state.source, selectedIndex: state.selectedIndex, group: action.group ?? null, time: now }
47
+ const past = coalesce ? [...state.history.past.slice(0, -1), { ...last, time: now }] : [...state.history.past, entry].slice(-HISTORY_LIMIT)
48
+ const { deck, diagnostics } = derive(source, state.manifests)
49
+ return { ...state, source, deck, diagnostics, selectedIndex: clamp(index, deck), history: { past, future: [] }, status: state.status === 'conflict' ? 'conflict' : 'unsaved' }
50
+ }
51
+ case 'undo':
52
+ case 'redo': {
53
+ const from = action.type === 'undo' ? state.history.past : state.history.future
54
+ const entry = from.at(-1)
55
+ if (!entry) return state
56
+ const current = { source: state.source, selectedIndex: state.selectedIndex, group: null, time: 0 }
57
+ const { deck, diagnostics } = derive(entry.source, state.manifests)
58
+ const history = action.type === 'undo'
59
+ ? { past: from.slice(0, -1), future: [...state.history.future, current] }
60
+ : { past: [...state.history.past, current], future: from.slice(0, -1) }
61
+ return { ...state, source: entry.source, deck, diagnostics, selectedIndex: clamp(entry.selectedIndex, deck), history, status: state.status === 'conflict' ? 'conflict' : entry.source === state.savedSource ? 'saved' : 'unsaved' }
62
+ }
63
+ case 'select': return { ...state, selectedIndex: clamp(action.index, state.deck) }
64
+ case 'setTab': return { ...state, tab: action.tab }
65
+ case 'setInspectorMode': return { ...state, inspectorMode: action.mode }
66
+ case 'saving': return state.status === 'conflict' ? state : { ...state, status: 'saving', error: null }
67
+ case 'saved': return { ...state, hash: action.hash, savedSource: action.source, status: state.status === 'conflict' ? 'conflict' : action.source === state.source ? 'saved' : 'unsaved', error: null }
68
+ case 'saveError': return { ...state, status: 'error', error: action.message }
69
+ case 'conflict': return { ...state, status: 'conflict', conflict: { source: action.source, hash: action.hash } }
70
+ case 'resolveConflict': {
71
+ if (!state.conflict) return state
72
+ if (action.choice === 'overwrite') return { ...state, hash: state.conflict.hash, conflict: null, status: 'unsaved' }
73
+ const { deck, diagnostics } = derive(state.conflict.source, state.manifests)
74
+ const entry = { source: state.source, selectedIndex: state.selectedIndex, group: null, time: 0 }
75
+ return { ...state, source: state.conflict.source, savedSource: state.conflict.source, hash: state.conflict.hash, deck, diagnostics, selectedIndex: clamp(state.selectedIndex, deck), history: { past: [...state.history.past, entry].slice(-HISTORY_LIMIT), future: [] }, conflict: null, status: 'saved' }
76
+ }
77
+ case 'externalChange': {
78
+ if (action.hash === state.hash) return state
79
+ if (state.status !== 'saved') return reduce(state, { type: 'conflict', source: action.source, hash: action.hash })
80
+ const { deck, diagnostics } = derive(action.source, state.manifests)
81
+ const entry = { source: state.source, selectedIndex: state.selectedIndex, group: null, time: 0 }
82
+ return { ...state, source: action.source, savedSource: action.source, hash: action.hash, deck, diagnostics, selectedIndex: clamp(state.selectedIndex, deck), history: { past: [...state.history.past, entry].slice(-HISTORY_LIMIT), future: [] }, status: 'saved' }
83
+ }
84
+ case 'setRegistry': {
85
+ const manifests = { layouts: byId(action.registry?.layouts), themes: byId(action.registry?.themes), palettes: byId(action.registry?.palettes) }
86
+ const { deck, diagnostics } = derive(state.source, manifests)
87
+ return { ...state, registry: action.registry, manifests, warnings: action.registry?.warnings ?? state.warnings, deck, diagnostics }
88
+ }
89
+ case 'previewRendered': return { ...state, preview: { error: action.error ?? null, slideCount: action.slideCount ?? 0 } }
90
+ default: return state
91
+ }
92
+ }