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,193 @@
1
+ import { readerLink } from '../core/urls.js'
2
+ import { h } from 'preact'
3
+ import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
4
+ import { marked } from 'marked'
5
+ import { deckOutline } from '../core/outline.js'
6
+ import { manifests, SlideRenderer } from '../layouts/renderSlide'
7
+ import { loadTheme, THEME_METAS } from './themeLoader'
8
+ import './reader.css'
9
+ import { t, stageLabels } from '../core/labels.js'
10
+ import { followActiveRooms } from '../live/follow.js'
11
+ import { Icon } from '../components/Icon.jsx'
12
+
13
+ // The reader view for decks sent around by email: outline, the slides at a
14
+ // comfortable size or stacked for reading, light or dark, PDF and a way back
15
+ // into the full-screen deck. The theme and palette stay the sender's: a sent
16
+ // file carries only those, with their fonts.
17
+
18
+ const lookKey = deckConfig => `mdeck-reader-look:${deckConfig.meta?.title ?? location.pathname}`
19
+
20
+ // The reader's choice of light or dark, '' for the deck's own.
21
+ function restoreAppearance(deckConfig) {
22
+ try {
23
+ const stored = JSON.parse(localStorage.getItem(lookKey(deckConfig)) ?? 'null')
24
+ if (['light', 'dark'].includes(stored?.appearance)) return stored.appearance
25
+ } catch {}
26
+ return ''
27
+ }
28
+
29
+ // Every button carries an icon so the bar reads at a glance.
30
+ const ReaderIcon = ({ name }) => <Icon name={name} class="reader-icon" />
31
+
32
+ function pdfLink() {
33
+ return document.querySelector('link[rel="alternate"][type="application/pdf"]')?.getAttribute('href') ?? null
34
+ }
35
+
36
+ function initialIndex(slides) {
37
+ const hash = decodeURIComponent(location.hash.slice(1))
38
+ const byId = slides.findIndex(slide => slide.id === hash)
39
+ if (byId >= 0) return byId
40
+ const n = parseInt(hash, 10)
41
+ return Number.isInteger(n) && n >= 1 && n <= slides.length ? n - 1 : 0
42
+ }
43
+
44
+ function ReadPage({ slide, index, total, deckConfig, width, height, scale, showNotes }) {
45
+ const notes = slide.meta.notes ?? ''
46
+ return <article class="reader-page" id={`reader-page-${index}`}>
47
+ <div class="reader-page-frame" style={{ aspectRatio: `${width} / ${height}` }}>
48
+ <div class="reader-page-scale" style={{ width: `${width}px`, height: `${height}px`, transform: `scale(${scale})` }}>
49
+ <SlideRenderer id={slide.id} regions={slide.regions} meta={slide.meta} content={slide.content} deckConfig={deckConfig} index={index} total={total} />
50
+ </div>
51
+ </div>
52
+ {showNotes && notes && <div class="reader-page-notes" dangerouslySetInnerHTML={{ __html: marked.parse(notes) }} />}
53
+ </article>
54
+ }
55
+
56
+ export function ReaderView({ deck, deckConfig }) {
57
+ const { slides } = deck
58
+ const width = deckConfig.width ?? 1920, height = deckConfig.height ?? 1080
59
+ const [appearance, setAppearance] = useState(() => restoreAppearance(deckConfig))
60
+ const [mode, setMode] = useState('slides')
61
+ const [index, setIndex] = useState(() => initialIndex(slides))
62
+ const [navOpen, setNavOpen] = useState(false)
63
+ const [toast, setToast] = useState('')
64
+ const [readScale, setReadScale] = useState(0.5)
65
+ const stageRef = useRef(null)
66
+ const readRef = useRef(null)
67
+ const outline = useMemo(() => deckOutline(deck, manifests, { slideName: n => t('reader.slide', { n }) }), [deck])
68
+ const ownAppearance = deckConfig.appearance ?? THEME_METAS[deckConfig.theme ?? 'neue']?.appearance ?? 'light'
69
+ const shown = appearance || ownAppearance
70
+ const toggleAllowed = deckConfig.reader?.themes !== false
71
+ const pdf = useMemo(pdfLink, [])
72
+ // Notes stay private unless the deck opts in; a file may carry them for the presenter.
73
+ const showNotes = deckConfig.reader?.notes === true && slides.some(slide => slide.meta.notes)
74
+
75
+ useEffect(() => {
76
+ loadTheme({ ...deckConfig, appearance: shown }).catch(error => console.warn(error.message))
77
+ try { localStorage.setItem(lookKey(deckConfig), JSON.stringify({ appearance })) } catch {}
78
+ }, [appearance])
79
+
80
+ useEffect(() => {
81
+ const stage = stageRef.current
82
+ if (!stage) return
83
+ stage.setLabels(stageLabels())
84
+ const stopFollowing = followActiveRooms(stage, slides)
85
+ const onChange = event => setIndex(event.detail.index)
86
+ stage.addEventListener('slidechange', onChange)
87
+ return () => { stage.removeEventListener('slidechange', onChange); stopFollowing() }
88
+ }, [mode])
89
+
90
+ // Read mode shows every slide finished: all steps revealed, as in print.
91
+ useEffect(() => {
92
+ if (mode !== 'read' || !readRef.current) return
93
+ readRef.current.querySelectorAll('[data-step]').forEach(step => step.setAttribute('data-step-visible', ''))
94
+ }, [mode])
95
+
96
+ useEffect(() => {
97
+ if (mode !== 'read' || !readRef.current) return
98
+ const measure = () => { const page = readRef.current?.querySelector('.reader-page'); if (page) setReadScale(page.clientWidth / width) }
99
+ measure()
100
+ const observer = new ResizeObserver(measure)
101
+ observer.observe(readRef.current)
102
+ return () => observer.disconnect()
103
+ }, [mode, width])
104
+
105
+ useEffect(() => {
106
+ if (mode !== 'read') return
107
+ const container = readRef.current
108
+ if (!container) return
109
+ const pages = [...container.querySelectorAll('.reader-page')]
110
+ const observer = new IntersectionObserver(entries => {
111
+ const visible = entries.filter(entry => entry.isIntersecting).sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0]
112
+ if (visible) setIndex(pages.indexOf(visible.target))
113
+ }, { root: container, threshold: [0.5] })
114
+ pages.forEach(page => observer.observe(page))
115
+ return () => observer.disconnect()
116
+ }, [mode, readScale])
117
+
118
+ const goTo = target => {
119
+ const i = Math.max(0, Math.min(slides.length - 1, target))
120
+ setIndex(i)
121
+ setNavOpen(false)
122
+ if (mode === 'slides') stageRef.current?.goTo(i)
123
+ else document.getElementById(`reader-page-${i}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
124
+ history.replaceState(null, '', `#${encodeURIComponent(slides[i]?.id ?? String(i + 1))}`)
125
+ }
126
+
127
+ const switchMode = next => {
128
+ setMode(next)
129
+ if (next === 'read') setTimeout(() => document.getElementById(`reader-page-${index}`)?.scrollIntoView({ block: 'start' }), 50)
130
+ }
131
+
132
+ const copyLink = async () => {
133
+ const url = readerLink(location.href, slides[index]?.id ?? String(index + 1))
134
+ try { await navigator.clipboard.writeText(url.toString()); setToast(t('reader.linkCopied')) } catch { setToast(url.toString()) }
135
+ setTimeout(() => setToast(''), 1800)
136
+ }
137
+
138
+ const savePdf = () => {
139
+ if (mode !== 'slides') switchMode('slides')
140
+ setTimeout(() => window.print(), 150)
141
+ }
142
+
143
+ const meta = deckConfig.meta ?? {}
144
+ const metaLine = [meta.author, meta.organization, meta.date].filter(Boolean).join(' · ')
145
+ const other = shown === 'dark' ? 'light' : 'dark'
146
+
147
+ return <div class="reader-view">
148
+ <header class="reader-top">
149
+ <button class="reader-btn reader-nav-toggle" onClick={() => setNavOpen(open => !open)} aria-label={t('reader.outline')}><ReaderIcon name="menu" /></button>
150
+ <h1>{meta.title ?? t('reader.untitled')}</h1>
151
+ {metaLine && <span class="reader-meta">{metaLine}</span>}
152
+ <span class="reader-spacer" />
153
+ <span class="reader-count">{index + 1} / {slides.length}</span>
154
+ <button class={`reader-btn${mode === 'slides' ? ' is-active' : ''}`} onClick={() => switchMode('slides')}><ReaderIcon name="slides" />{t('reader.slides')}</button>
155
+ <button class={`reader-btn${mode === 'read' ? ' is-active' : ''}`} onClick={() => switchMode('read')}><ReaderIcon name="read" />{t('reader.read')}</button>
156
+ {toggleAllowed && <button class="reader-btn" onClick={() => setAppearance(other === ownAppearance ? '' : other)} title={t('reader.appearance')}><ReaderIcon name={other === 'dark' ? 'moon' : 'sun'} />{t(other === 'dark' ? 'reader.dark' : 'reader.light')}</button>}
157
+ {pdf
158
+ ? <a class="reader-btn" href={pdf} download={`${(meta.title ?? 'slides').replace(/[^\w.-]+/g, '-')}.pdf`}><ReaderIcon name="download" />{t('reader.downloadPdf')}</a>
159
+ : <button class="reader-btn" onClick={savePdf} title={t('reader.savePdfHint')}><ReaderIcon name="download" />{t('reader.savePdf')}</button>}
160
+ <a class="reader-btn" href={`?view=deck#${encodeURIComponent(slides[index]?.id ?? String(index + 1))}`}><ReaderIcon name="present" />{t('reader.present')}</a>
161
+ </header>
162
+ <div class="reader-main">
163
+ <nav class={`reader-nav${navOpen ? ' is-open' : ''}`} aria-label={t('reader.slides')}>
164
+ <ol>
165
+ {outline.map(item => <li key={item.index} class={`${item.index === index ? 'is-current' : ''}${item.chapter ? ' is-chapter' : ''}`} onClick={() => goTo(item.index)}>
166
+ <span class="reader-num">{String(item.index + 1).padStart(2, '0')}</span>
167
+ <span class="reader-title">{item.chapter && item.part && <span class="reader-part">{item.part}</span>}{item.title}</span>
168
+ </li>)}
169
+ </ol>
170
+ </nav>
171
+ <section class="reader-content">
172
+ {mode === 'slides'
173
+ ? <>
174
+ <div class="reader-stage-wrap">
175
+ <deck-stage ref={stageRef} width={width} height={height}>
176
+ {slides.map((slide, i) => <SlideRenderer key={slide.id} id={slide.id} regions={slide.regions} meta={slide.meta} content={slide.content} deckConfig={deckConfig} index={i} total={slides.length} />)}
177
+ </deck-stage>
178
+ </div>
179
+ <div class="reader-bottom">
180
+ <button class="reader-btn" onClick={() => stageRef.current?.prev('click')}><ReaderIcon name="prev" />{t('reader.previous')}</button>
181
+ <button class="reader-btn" onClick={() => stageRef.current?.next('click')}>{t('reader.next')}<ReaderIcon name="next" /></button>
182
+ <span class="reader-spacer" />
183
+ <button class="reader-btn" onClick={copyLink}><ReaderIcon name="link" />{t('reader.copyLink')}</button>
184
+ </div>
185
+ </>
186
+ : <div class="reader-read" ref={readRef} data-deck-static>
187
+ {slides.map((slide, i) => <ReadPage key={slide.id} slide={slide} index={i} total={slides.length} deckConfig={deckConfig} width={width} height={height} scale={readScale} showNotes={showNotes} />)}
188
+ </div>}
189
+ </section>
190
+ </div>
191
+ {toast && <div class="reader-toast">{toast}</div>}
192
+ </div>
193
+ }
@@ -0,0 +1,28 @@
1
+ import { h, Component } from 'preact'
2
+
3
+ // Keeps one broken slide from blanking the whole preview while editing. The
4
+ // stage still counts the slide, and the error clears when its source changes.
5
+ export class SlideErrorBoundary extends Component {
6
+ constructor(props) {
7
+ super(props)
8
+ this.state = { error: null, source: props.source }
9
+ }
10
+
11
+ static getDerivedStateFromProps(props, state) {
12
+ return props.source !== state.source ? { error: null, source: props.source } : null
13
+ }
14
+
15
+ componentDidCatch(error) {
16
+ this.setState({ error: error?.message ?? String(error) })
17
+ }
18
+
19
+ render({ id, children }, { error }) {
20
+ if (!error) return children
21
+ return <section class="slide slide--error" data-slide-id={id} data-label="Error">
22
+ <div class="slide-body" style={{ padding: '80px', fontFamily: 'ui-monospace, monospace', fontSize: '28px' }}>
23
+ <p>This slide could not be rendered.</p>
24
+ <pre style={{ whiteSpace: 'pre-wrap' }}>{error}</pre>
25
+ </div>
26
+ </section>
27
+ }
28
+ }
@@ -0,0 +1,55 @@
1
+ import { h } from 'preact'
2
+
3
+ // Dark control chrome shared by the presenter view and the editor; the editor's
4
+ // stylesheet and the launch page use the same values.
5
+ export const S = {
6
+ btn: {
7
+ appearance: 'none',
8
+ WebkitAppearance: 'none',
9
+ display: 'inline-flex',
10
+ alignItems: 'center',
11
+ justifyContent: 'center',
12
+ gap: '5px',
13
+ padding: '5px 12px',
14
+ borderRadius: '5px',
15
+ border: '1px solid #3a3a3a',
16
+ background: '#222',
17
+ color: '#e0e0e0',
18
+ cursor: 'pointer',
19
+ fontSize: '13px',
20
+ fontFamily: 'inherit',
21
+ lineHeight: '1.5',
22
+ outline: 'none',
23
+ },
24
+ select: {
25
+ padding: '5px 8px',
26
+ borderRadius: '5px',
27
+ border: '1px solid #3a3a3a',
28
+ background: '#222',
29
+ color: '#e0e0e0',
30
+ cursor: 'pointer',
31
+ fontSize: '13px',
32
+ fontFamily: 'inherit',
33
+ width: '100%',
34
+ },
35
+ label: {
36
+ color: '#a0a0a0',
37
+ fontSize: '11px',
38
+ textTransform: 'uppercase',
39
+ letterSpacing: '0.08em',
40
+ marginBottom: '5px',
41
+ display: 'block',
42
+ },
43
+ }
44
+
45
+ export function PaletteSwatches({ tokens }) {
46
+ const keys = ['--bg', '--surface', '--rule', '--accent', '--accent-2', '--ink']
47
+ return (
48
+ <div style={{ display: 'flex', gap: '2px', alignItems: 'center', flexShrink: 0 }}>
49
+ {keys.map(k => tokens[k]
50
+ ? <div key={k} style={{ width: '10px', height: '10px', borderRadius: '2px', background: tokens[k], border: '1px solid rgba(255,255,255,0.08)', flexShrink: 0 }} />
51
+ : null
52
+ )}
53
+ </div>
54
+ )
55
+ }