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.
- package/CHANGELOG.md +259 -0
- package/LICENSE +21 -0
- package/README.md +156 -0
- package/assets/base.css +196 -0
- package/assets/extensions/layouts/chapter/extension.toml +26 -0
- package/assets/extensions/layouts/chapter/layout.jsx +21 -0
- package/assets/extensions/layouts/chapter/starter.md +8 -0
- package/assets/extensions/layouts/focus/extension.toml +8 -0
- package/assets/extensions/layouts/focus/layout.jsx +10 -0
- package/assets/extensions/layouts/focus/starter.md +4 -0
- package/assets/extensions/layouts/full-bleed-image/extension.toml +31 -0
- package/assets/extensions/layouts/full-bleed-image/layout.jsx +19 -0
- package/assets/extensions/layouts/full-bleed-image/starter.md +6 -0
- package/assets/extensions/layouts/generic/extension.toml +8 -0
- package/assets/extensions/layouts/generic/layout.jsx +8 -0
- package/assets/extensions/layouts/generic/starter.md +4 -0
- package/assets/extensions/layouts/image-text/extension.toml +25 -0
- package/assets/extensions/layouts/image-text/layout.jsx +17 -0
- package/assets/extensions/layouts/image-text/starter.md +6 -0
- package/assets/extensions/layouts/split/extension.toml +25 -0
- package/assets/extensions/layouts/split/layout.jsx +19 -0
- package/assets/extensions/layouts/split/starter.md +14 -0
- package/assets/extensions/layouts/title/extension.toml +26 -0
- package/assets/extensions/layouts/title/layout.jsx +14 -0
- package/assets/extensions/layouts/title/starter.md +5 -0
- package/assets/extensions/palettes/brand/extension.toml +29 -0
- package/assets/extensions/palettes/cobalt/extension.toml +27 -0
- package/assets/extensions/palettes/ember/extension.toml +27 -0
- package/assets/extensions/palettes/forest/extension.toml +27 -0
- package/assets/extensions/palettes/graphite/extension.toml +27 -0
- package/assets/extensions/palettes/lagoon/extension.toml +27 -0
- package/assets/extensions/palettes/neon/extension.toml +27 -0
- package/assets/extensions/palettes/nordic/extension.toml +27 -0
- package/assets/extensions/palettes/pastel/extension.toml +30 -0
- package/assets/extensions/palettes/phosphor/extension.toml +27 -0
- package/assets/extensions/palettes/swiss/extension.toml +27 -0
- package/assets/extensions/palettes/terra/extension.toml +27 -0
- package/assets/extensions/themes/academic/extension.toml +53 -0
- package/assets/extensions/themes/academic/styles.css +631 -0
- package/assets/extensions/themes/aurora/extension.toml +37 -0
- package/assets/extensions/themes/aurora/styles.css +644 -0
- package/assets/extensions/themes/duet/extension.toml +46 -0
- package/assets/extensions/themes/duet/styles.css +573 -0
- package/assets/extensions/themes/editorial/extension.toml +46 -0
- package/assets/extensions/themes/editorial/styles.css +574 -0
- package/assets/extensions/themes/fhnw/extension.toml +61 -0
- package/assets/extensions/themes/fhnw/styles.css +604 -0
- package/assets/extensions/themes/neue/extension.toml +46 -0
- package/assets/extensions/themes/neue/styles.css +489 -0
- package/assets/extensions/themes/sketch/extension.toml +48 -0
- package/assets/extensions/themes/sketch/styles.css +633 -0
- package/assets/extensions/themes/terminal/extension.toml +38 -0
- package/assets/extensions/themes/terminal/styles.css +550 -0
- package/assets/logo/avatar.png +0 -0
- package/assets/logo/logo-dark.svg +10 -0
- package/assets/logo/logo-light.svg +10 -0
- package/assets/logo/mark.svg +15 -0
- package/bin/mdeck.js +2 -0
- package/docs/README.md +8 -0
- package/docs/reference/authoring.md +795 -0
- package/docs/reference/claude-skill.md +96 -0
- package/docs/reference/extensions.md +250 -0
- package/docs/reference/layouts.md +153 -0
- package/docs/reference/palettes.md +106 -0
- package/docs/reference/structured-slides.md +120 -0
- package/docs/reference/themes.md +133 -0
- package/docs/site/assets/favicon.svg +15 -0
- package/docs/site/assets/github-logo.svg +1 -0
- package/docs/site/assets/site.css +162 -0
- package/docs/site/assets/site.js +123 -0
- package/docs/site/build.js +148 -0
- package/docs/site/content/appearance.md +203 -0
- package/docs/site/content/audience.md +178 -0
- package/docs/site/content/commands.md +153 -0
- package/docs/site/content/components.md +134 -0
- package/docs/site/content/drawing.md +72 -0
- package/docs/site/content/editing.md +59 -0
- package/docs/site/content/getting-started.md +103 -0
- package/docs/site/content/layouts.md +126 -0
- package/docs/site/content/media.md +107 -0
- package/docs/site/content/more-content.md +130 -0
- package/docs/site/content/presenting.md +93 -0
- package/docs/site/content/reusable-layouts.md +74 -0
- package/docs/site/content/sharing.md +88 -0
- package/docs/site/content/theme-authoring.md +144 -0
- package/docs/site/content/theme-repository.md +76 -0
- package/docs/site/content/troubleshooting.md +92 -0
- package/docs/site/content/views.md +56 -0
- package/docs/site/content/welcome.md +71 -0
- package/docs/site/content/writing.md +121 -0
- package/docs/site/examples/first-talk.md +88 -0
- package/docs/site/examples/notebook.svg +58 -0
- package/docs/site/images/themes/academic-dark.webp +0 -0
- package/docs/site/images/themes/academic-light.webp +0 -0
- package/docs/site/images/themes/academic.webp +0 -0
- package/docs/site/images/themes/aurora-dark.webp +0 -0
- package/docs/site/images/themes/aurora-light.webp +0 -0
- package/docs/site/images/themes/aurora.webp +0 -0
- package/docs/site/images/themes/duet-dark.webp +0 -0
- package/docs/site/images/themes/duet-light.webp +0 -0
- package/docs/site/images/themes/duet.webp +0 -0
- package/docs/site/images/themes/editorial-dark.webp +0 -0
- package/docs/site/images/themes/editorial-light.webp +0 -0
- package/docs/site/images/themes/editorial.webp +0 -0
- package/docs/site/images/themes/fhnw.webp +0 -0
- package/docs/site/images/themes/neue-dark.webp +0 -0
- package/docs/site/images/themes/neue-light.webp +0 -0
- package/docs/site/images/themes/neue.webp +0 -0
- package/docs/site/images/themes/sketch-dark.webp +0 -0
- package/docs/site/images/themes/sketch-light.webp +0 -0
- package/docs/site/images/themes/sketch.webp +0 -0
- package/docs/site/images/themes/terminal-dark.webp +0 -0
- package/docs/site/images/themes/terminal-light.webp +0 -0
- package/docs/site/images/themes/terminal.webp +0 -0
- package/docs/site/package.json +9 -0
- package/docs/site/pages.js +27 -0
- package/docs/site/server.js +58 -0
- package/examples/custom-layouts/extensions/comparison/extension.toml +35 -0
- package/examples/custom-layouts/extensions/comparison/layout.jsx +15 -0
- package/examples/custom-layouts/extensions/comparison/starter.md +17 -0
- package/examples/custom-layouts/extensions/comparison/styles.css +4 -0
- package/examples/custom-layouts/slides.md +60 -0
- package/examples/poll/slides.md +127 -0
- package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T08-36-47Z.json +19 -0
- package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T11-27-30Z.json +6 -0
- package/examples/python/slides.drawings.json +6 -0
- package/examples/python/slides.md +493 -0
- package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T08-08-30Z.json +10 -0
- package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T11-41-35Z.json +13 -0
- package/examples/showcase/img/image.jpg +0 -0
- package/examples/showcase/img/mdeck.svg +66 -0
- package/examples/showcase/slides.drawings.json +11 -0
- package/examples/showcase/slides.md +427 -0
- package/package.json +75 -0
- package/skills/write-slides/SKILL.md +144 -0
- package/src/build/check.js +49 -0
- package/src/build/chrome.js +130 -0
- package/src/build/components.js +44 -0
- package/src/build/config.js +61 -0
- package/src/build/drawings.js +14 -0
- package/src/build/editorPlugin.js +272 -0
- package/src/build/editorServer.js +36 -0
- package/src/build/fonts.js +84 -0
- package/src/build/homePlugin.js +208 -0
- package/src/build/inkPlugin.js +192 -0
- package/src/build/ownWrites.js +18 -0
- package/src/build/pairing.js +81 -0
- package/src/build/pdf.js +34 -0
- package/src/build/relayAccess.js +85 -0
- package/src/build/slidesPlugin.js +371 -0
- package/src/build/themeCheck.js +151 -0
- package/src/build/tunnelClient.js +184 -0
- package/src/cli/args.js +76 -0
- package/src/cli/index.js +758 -0
- package/src/cli/migrate.js +123 -0
- package/src/cli/skill.js +40 -0
- package/src/cli/themes.js +140 -0
- package/src/components/CodeBlock.jsx +168 -0
- package/src/components/ConnectedViews.jsx +25 -0
- package/src/components/Icon.jsx +8 -0
- package/src/components/Poll.jsx +67 -0
- package/src/components/QrCode.jsx +29 -0
- package/src/components/QrCodeTag.jsx +27 -0
- package/src/components/Question.jsx +28 -0
- package/src/components/Scale.jsx +47 -0
- package/src/components/VideoPlayer.jsx +108 -0
- package/src/components/WordCloud.jsx +106 -0
- package/src/components/activity.jsx +35 -0
- package/src/components/callout.css +78 -0
- package/src/components/code-block.css +123 -0
- package/src/components/columns.css +11 -0
- package/src/components/poll.css +81 -0
- package/src/components/video-player.css +14 -0
- package/src/core/devPath.js +4 -0
- package/src/core/editDeck.js +230 -0
- package/src/core/icons.js +69 -0
- package/src/core/ink.js +221 -0
- package/src/core/labels.js +144 -0
- package/src/core/outline.js +33 -0
- package/src/core/parseSlides.js +152 -0
- package/src/core/source.js +137 -0
- package/src/core/urls.js +34 -0
- package/src/core/validateDeck.js +139 -0
- package/src/editor/api.js +67 -0
- package/src/editor/components/App.jsx +75 -0
- package/src/editor/components/ConflictBanner.jsx +10 -0
- package/src/editor/components/DeckSettings.jsx +108 -0
- package/src/editor/components/DesignApp.jsx +252 -0
- package/src/editor/components/Diagnostics.jsx +10 -0
- package/src/editor/components/ExtensionEditor.jsx +66 -0
- package/src/editor/components/ExtensionForms.jsx +134 -0
- package/src/editor/components/ExtensionsPanel.jsx +114 -0
- package/src/editor/components/Field.jsx +24 -0
- package/src/editor/components/Inspector.jsx +36 -0
- package/src/editor/components/LayoutPicker.jsx +23 -0
- package/src/editor/components/LayoutSelect.jsx +25 -0
- package/src/editor/components/LookThumbnail.jsx +8 -0
- package/src/editor/components/MetaFields.jsx +24 -0
- package/src/editor/components/Outline.jsx +28 -0
- package/src/editor/components/PalettePicker.jsx +32 -0
- package/src/editor/components/Preview.jsx +63 -0
- package/src/editor/components/PropsForm.jsx +46 -0
- package/src/editor/components/RawSlideEditor.jsx +9 -0
- package/src/editor/components/RegionEditor.jsx +15 -0
- package/src/editor/components/Resizer.jsx +18 -0
- package/src/editor/designLink.js +19 -0
- package/src/editor/editor.css +139 -0
- package/src/editor/extensions.js +86 -0
- package/src/editor/keyboard.js +21 -0
- package/src/editor/model.js +38 -0
- package/src/editor/sampleDeck.js +165 -0
- package/src/editor/saveQueue.js +47 -0
- package/src/editor/schema.js +36 -0
- package/src/editor/state.js +92 -0
- package/src/editor/useDeckDocument.js +67 -0
- package/src/extensions/appearance.js +79 -0
- package/src/extensions/contrast.js +57 -0
- package/src/extensions/discover.js +126 -0
- package/src/extensions/manifest.js +209 -0
- package/src/extensions/packs.js +268 -0
- package/src/extensions/tokens.js +25 -0
- package/src/home/Home.jsx +362 -0
- package/src/home/home.css +148 -0
- package/src/layouts/layoutApi.jsx +205 -0
- package/src/layouts/layoutProps.js +30 -0
- package/src/layouts/renderSlide.jsx +17 -0
- package/src/live/answer/answer.css +34 -0
- package/src/live/answer/answer.html +14 -0
- package/src/live/answer/answer.js +130 -0
- package/src/live/client.js +290 -0
- package/src/live/code.js +19 -0
- package/src/live/follow.js +17 -0
- package/src/live/pairing.js +34 -0
- package/src/live/presence.js +33 -0
- package/src/live/roomTag.js +38 -0
- package/src/live/rooms.js +249 -0
- package/src/live/server.js +82 -0
- package/src/live/tunnel.js +180 -0
- package/src/paths.js +9 -0
- package/src/runtime/ReaderView.jsx +193 -0
- package/src/runtime/SlideErrorBoundary.jsx +28 -0
- package/src/runtime/chrome.jsx +55 -0
- package/src/runtime/deck-stage.js +1533 -0
- package/src/runtime/design.html +12 -0
- package/src/runtime/design.jsx +5 -0
- package/src/runtime/editor.html +12 -0
- package/src/runtime/editor.jsx +5 -0
- package/src/runtime/editorBridge.js +58 -0
- package/src/runtime/fit.js +149 -0
- package/src/runtime/home.html +13 -0
- package/src/runtime/home.jsx +5 -0
- package/src/runtime/index.html +15 -0
- package/src/runtime/ink/InkLayer.jsx +17 -0
- package/src/runtime/ink/Toolbar.jsx +145 -0
- package/src/runtime/ink/attach.js +69 -0
- package/src/runtime/ink/bus.js +126 -0
- package/src/runtime/ink/controller.js +132 -0
- package/src/runtime/ink/room.js +101 -0
- package/src/runtime/ink/select.js +124 -0
- package/src/runtime/ink/server.js +75 -0
- package/src/runtime/ink/store.js +55 -0
- package/src/runtime/ink/toolbar.css +79 -0
- package/src/runtime/main.jsx +863 -0
- package/src/runtime/markedSetup.js +147 -0
- package/src/runtime/reader.css +59 -0
- package/src/runtime/registry.jsx +26 -0
- package/src/runtime/syncOrder.js +32 -0
- package/src/runtime/themeLoader.js +68 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { h } from 'preact'
|
|
2
|
+
import { useRoom } from '../live/client.js'
|
|
3
|
+
import { JoinCode, ActivityFooter, wantsQr } from './activity.jsx'
|
|
4
|
+
import { t } from '../core/labels.js'
|
|
5
|
+
import './poll.css'
|
|
6
|
+
|
|
7
|
+
// <question room="ask" question="What should we cover next?" placeholder="Your question" />
|
|
8
|
+
// Open answers: phones get a text field and may send as often as they like;
|
|
9
|
+
// the slide shows the newest answers as cards (`limit`, 8 by default).
|
|
10
|
+
|
|
11
|
+
export default function Question({ room = 'question', question = '', limit = '8', qr }) {
|
|
12
|
+
const live = useRoom(room)
|
|
13
|
+
const answers = live.messages.map(message => ({ n: message.n, text: String(message.data?.value ?? '').trim() })).filter(answer => answer.text)
|
|
14
|
+
const shown = answers.slice(-Math.max(1, Number(limit) || 8)).reverse()
|
|
15
|
+
const code = wantsQr(qr)
|
|
16
|
+
return <div class="poll poll--question">
|
|
17
|
+
<div class="poll-main">
|
|
18
|
+
{question && <p class="poll-question">{question}</p>}
|
|
19
|
+
{shown.length
|
|
20
|
+
? <ul class="question-cards">{shown.map(answer => <li key={answer.n}>{answer.text}</li>)}</ul>
|
|
21
|
+
: !live.offline && <p class="question-empty">{t('question.empty')}</p>}
|
|
22
|
+
<ActivityFooter count={answers.length} {...live} />
|
|
23
|
+
</div>
|
|
24
|
+
<JoinCode room={room} {...live} qr={code} size={240} />
|
|
25
|
+
</div>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
Question.phone = ({ question, placeholder }, { slideTitle = '' } = {}) => ({ type: 'text', question: question || slideTitle, placeholder: placeholder ?? '', maxLength: 200 })
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { h } from 'preact'
|
|
2
|
+
import { useRoom, latestByDevice } from '../live/client.js'
|
|
3
|
+
import { JoinCode, ActivityFooter, wantsQr } from './activity.jsx'
|
|
4
|
+
import { t } from '../core/labels.js'
|
|
5
|
+
import './poll.css'
|
|
6
|
+
|
|
7
|
+
// <scale room="pace" question="How is the pace?" min="1" max="5" low="Too slow" high="Too fast" />
|
|
8
|
+
// Phones pick a number; each device's latest answer counts. The slide shows
|
|
9
|
+
// how many chose each number, and the average.
|
|
10
|
+
|
|
11
|
+
const bounds = ({ min, max }) => {
|
|
12
|
+
const low = Number.isFinite(Number(min)) && min !== '' && min != null ? Math.round(Number(min)) : 1
|
|
13
|
+
const high = Number.isFinite(Number(max)) && max !== '' && max != null ? Math.round(Number(max)) : 5
|
|
14
|
+
return high > low && high - low <= 10 ? [low, high] : [1, 5]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function Scale({ room = 'scale', question = '', low = '', high = '', qr, ...rest }) {
|
|
18
|
+
const [min, max] = bounds(rest)
|
|
19
|
+
const steps = Array.from({ length: max - min + 1 }, (_, i) => min + i)
|
|
20
|
+
const live = useRoom(room)
|
|
21
|
+
const values = latestByDevice(live.messages).map(message => Number(message.data?.value)).filter(value => steps.includes(value))
|
|
22
|
+
const counts = steps.map(step => values.filter(value => value === step).length)
|
|
23
|
+
const most = Math.max(1, ...counts)
|
|
24
|
+
const average = values.length ? (values.reduce((sum, value) => sum + value, 0) / values.length).toFixed(1) : null
|
|
25
|
+
const code = wantsQr(qr)
|
|
26
|
+
return <div class={`poll poll--scale${live.offline && !values.length ? ' is-static' : ''}`}>
|
|
27
|
+
<div class="poll-main">
|
|
28
|
+
{question && <p class="poll-question">{question}</p>}
|
|
29
|
+
<div class="scale-columns" role="list">
|
|
30
|
+
{steps.map((step, i) => <div class="scale-column" role="listitem" key={step}>
|
|
31
|
+
<span class="scale-count">{counts[i]}</span>
|
|
32
|
+
<span class="scale-track"><span class="scale-fill" style={{ height: `${(counts[i] / most) * 100}%` }} /></span>
|
|
33
|
+
<span class="scale-step">{step}</span>
|
|
34
|
+
</div>)}
|
|
35
|
+
</div>
|
|
36
|
+
{(low || high) && <p class="scale-labels"><span>{low}</span><span>{high}</span></p>}
|
|
37
|
+
<ActivityFooter count={values.length} {...live} />
|
|
38
|
+
{average && <p class="scale-average">{t('scale.average', { n: average })}</p>}
|
|
39
|
+
</div>
|
|
40
|
+
<JoinCode room={room} {...live} qr={code} size={240} />
|
|
41
|
+
</div>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
Scale.phone = ({ question, low, high, ...rest }, { slideTitle = '' } = {}) => {
|
|
45
|
+
const [min, max] = bounds(rest)
|
|
46
|
+
return { type: 'scale', question: question || slideTitle, min, max, minLabel: low ?? '', maxLabel: high ?? '' }
|
|
47
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { h } from 'preact'
|
|
2
|
+
import { useEffect, useRef, useState } from 'preact/hooks'
|
|
3
|
+
import './video-player.css'
|
|
4
|
+
|
|
5
|
+
function getEmbedUrl(rawUrl, autoplay = false) {
|
|
6
|
+
const ap = autoplay
|
|
7
|
+
const yt = rawUrl.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/)
|
|
8
|
+
if (yt) return `https://www.youtube.com/embed/${yt[1]}?rel=0${ap ? '&autoplay=1&mute=1' : ''}`
|
|
9
|
+
const vi = rawUrl.match(/vimeo\.com\/(\d+)/)
|
|
10
|
+
if (vi) return `https://player.vimeo.com/video/${vi[1]}${ap ? '?autoplay=1&muted=1' : ''}`
|
|
11
|
+
const sw = rawUrl.match(/tube\.switch\.ch\/(?:videos|embed)\/([\w-]+)/)
|
|
12
|
+
if (sw) return `https://tube.switch.ch/embed/${sw[1]}${ap ? '?autoplay=1' : ''}`
|
|
13
|
+
return rawUrl
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default function VideoPlayer({ src, url, play = 'click', aspect, muted }) {
|
|
17
|
+
const containerRef = useRef(null)
|
|
18
|
+
const videoRef = useRef(null)
|
|
19
|
+
|
|
20
|
+
// The HtmlContent hydration wrapper has no width; stretch it so percentage widths work
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (containerRef.current?.parentElement) {
|
|
23
|
+
containerRef.current.parentElement.style.width = '100%'
|
|
24
|
+
}
|
|
25
|
+
}, [])
|
|
26
|
+
|
|
27
|
+
// Preact doesn't reliably apply the muted attribute via props — set it on the DOM node directly
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (videoRef.current) videoRef.current.muted = (play === 'auto' || muted != null)
|
|
30
|
+
}, [play, muted])
|
|
31
|
+
// Web video: click mode renders immediately; auto mode starts null and is set by slidechange
|
|
32
|
+
const [iframeSrc, setIframeSrc] = useState(
|
|
33
|
+
url && play === 'click' ? getEmbedUrl(url, false) : null
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
// Local video: pause on slide deactivation for all modes; also play on activation for "auto"
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!src) return
|
|
39
|
+
const stage = document.querySelector('deck-stage')
|
|
40
|
+
if (!stage) return
|
|
41
|
+
|
|
42
|
+
// If already on the active slide when we mount, play immediately for auto mode
|
|
43
|
+
if (play === 'auto') {
|
|
44
|
+
const active = stage.querySelector('[data-deck-active]')
|
|
45
|
+
if (active?.contains(containerRef.current)) videoRef.current?.play()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function onSlideChange(e) {
|
|
49
|
+
const active = e.detail.slide?.contains(containerRef.current)
|
|
50
|
+
if (active && play === 'auto') {
|
|
51
|
+
videoRef.current?.play()
|
|
52
|
+
} else if (!active) {
|
|
53
|
+
videoRef.current?.pause()
|
|
54
|
+
if (play === 'auto' && videoRef.current) videoRef.current.currentTime = 0
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
stage.addEventListener('slidechange', onSlideChange)
|
|
58
|
+
return () => stage.removeEventListener('slidechange', onSlideChange)
|
|
59
|
+
}, [play, src])
|
|
60
|
+
|
|
61
|
+
// Web video auto mode: clear/restore src on slide change to stop/start playback
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (play !== 'auto' || !url) return
|
|
64
|
+
const stage = document.querySelector('deck-stage')
|
|
65
|
+
if (!stage) return
|
|
66
|
+
const embedUrl = getEmbedUrl(url, true)
|
|
67
|
+
|
|
68
|
+
// Check immediately in case we mounted after the init slidechange fired
|
|
69
|
+
const active = stage.querySelector('[data-deck-active]')
|
|
70
|
+
if (active?.contains(containerRef.current)) setIframeSrc(embedUrl)
|
|
71
|
+
|
|
72
|
+
function onSlideChange(e) {
|
|
73
|
+
const active = e.detail.slide?.contains(containerRef.current)
|
|
74
|
+
setIframeSrc(active ? embedUrl : null)
|
|
75
|
+
}
|
|
76
|
+
stage.addEventListener('slidechange', onSlideChange)
|
|
77
|
+
return () => stage.removeEventListener('slidechange', onSlideChange)
|
|
78
|
+
}, [play, url])
|
|
79
|
+
|
|
80
|
+
const fixedAspect = typeof aspect === 'string' && aspect.trim() !== ''
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div
|
|
84
|
+
ref={containerRef}
|
|
85
|
+
class="video-player-container"
|
|
86
|
+
// max-height: 800px würde das aspectRatio sonst überstimmen: der Kasten
|
|
87
|
+
// wird flacher als das Video und dieses innen eingepasst -> schwarze
|
|
88
|
+
// Ränder. Also die Breite passend zur Höhenbegrenzung mitdeckeln.
|
|
89
|
+
style={fixedAspect
|
|
90
|
+
? { aspectRatio: aspect, maxWidth: `calc(800px * (${aspect}))`, margin: '0 auto' }
|
|
91
|
+
: undefined}
|
|
92
|
+
>
|
|
93
|
+
{url ? (
|
|
94
|
+
iframeSrc
|
|
95
|
+
? <iframe src={iframeSrc} allow="autoplay; fullscreen" allowFullScreen
|
|
96
|
+
style={{ width: '100%', height: fixedAspect ? '100%' : 'auto', aspectRatio: fixedAspect ? undefined : '16 / 9', border: '0', display: 'block' }} />
|
|
97
|
+
: <div class="video-player-placeholder" />
|
|
98
|
+
) : (
|
|
99
|
+
<video
|
|
100
|
+
ref={videoRef}
|
|
101
|
+
src={src}
|
|
102
|
+
controls
|
|
103
|
+
style={{ width: '100%', height: fixedAspect ? '100%' : 'auto', display: 'block' }}
|
|
104
|
+
/>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { h } from 'preact'
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
|
3
|
+
import cloud from 'd3-cloud'
|
|
4
|
+
import { useRoom } from '../live/client.js'
|
|
5
|
+
import { JoinCode, ActivityFooter, wantsQr } from './activity.jsx'
|
|
6
|
+
import { t } from '../core/labels.js'
|
|
7
|
+
import './poll.css'
|
|
8
|
+
|
|
9
|
+
// <wordcloud room="mood" question="One word for today?" />
|
|
10
|
+
// Phones send a word or two, as often as they like. The slide packs every
|
|
11
|
+
// answer into a cloud around its middle: the more often it was sent, the
|
|
12
|
+
// larger; some stand upright. Answers that differ only in case or spacing
|
|
13
|
+
// count together. `height` is the cloud's height in slide pixels (520),
|
|
14
|
+
// `limit` the most words shown (60).
|
|
15
|
+
|
|
16
|
+
// The same answers always give the same cloud, and a new word moves the
|
|
17
|
+
// others as little as the layout allows.
|
|
18
|
+
function seeded(seed) {
|
|
19
|
+
let state = seed >>> 0 || 1
|
|
20
|
+
return () => { state = (state * 1664525 + 1013904223) >>> 0; return state / 2 ** 32 }
|
|
21
|
+
}
|
|
22
|
+
const hash = text => [...text].reduce((sum, char) => (sum * 31 + char.codePointAt(0)) >>> 0, 7)
|
|
23
|
+
|
|
24
|
+
function countWords(messages) {
|
|
25
|
+
const counts = new Map()
|
|
26
|
+
for (const message of messages) {
|
|
27
|
+
const text = String(message.data?.value ?? '').trim().replace(/\s+/g, ' ')
|
|
28
|
+
if (!text) continue
|
|
29
|
+
const key = text.toLocaleLowerCase()
|
|
30
|
+
const entry = counts.get(key) ?? { text, n: 0 }
|
|
31
|
+
entry.n++
|
|
32
|
+
counts.set(key, entry)
|
|
33
|
+
}
|
|
34
|
+
return [...counts.values()].sort((a, b) => b.n - a.n || a.text.localeCompare(b.text))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Places the words (d3-cloud: an Archimedean spiral from the middle, tested
|
|
38
|
+
// against the letters' real shapes). When not every word fits, it tries again
|
|
39
|
+
// smaller, so no answer is left out.
|
|
40
|
+
function layout(words, { width, height, font }) {
|
|
41
|
+
if (!words.length || width < 50) return []
|
|
42
|
+
const most = words[0].n
|
|
43
|
+
const largest = Math.min(150, height * 0.3)
|
|
44
|
+
let placed = []
|
|
45
|
+
for (let scale = 1; scale > 0.3; scale *= 0.82) {
|
|
46
|
+
const top = largest * scale, bottom = Math.max(18, 26 * scale)
|
|
47
|
+
cloud()
|
|
48
|
+
.size([width, height])
|
|
49
|
+
.words(words.map((word, i) => ({
|
|
50
|
+
text: word.text, n: word.n,
|
|
51
|
+
size: bottom + (top - bottom) * Math.sqrt(word.n / most),
|
|
52
|
+
rotate: i > 0 && hash(word.text) % 5 === 0 ? -90 : 0,
|
|
53
|
+
})))
|
|
54
|
+
.font(font).fontWeight('700').fontSize(word => word.size).rotate(word => word.rotate)
|
|
55
|
+
.padding(Math.max(2, 6 * scale)).spiral('archimedean')
|
|
56
|
+
.random(seeded(words.length * 7919 + hash(words[0].text)))
|
|
57
|
+
.timeInterval(Infinity)
|
|
58
|
+
.on('end', output => { placed = output })
|
|
59
|
+
.start()
|
|
60
|
+
if (placed.length === words.length) break
|
|
61
|
+
}
|
|
62
|
+
return placed
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export default function WordCloud({ room = 'words', question = '', limit = '60', height = '520', qr }) {
|
|
66
|
+
const live = useRoom(room)
|
|
67
|
+
const all = countWords(live.messages)
|
|
68
|
+
const words = all.slice(0, Math.max(1, Number(limit) || 60))
|
|
69
|
+
const total = all.reduce((sum, word) => sum + word.n, 0)
|
|
70
|
+
const cloudHeight = Math.max(200, Number(height) || 520)
|
|
71
|
+
const ref = useRef(null)
|
|
72
|
+
// Slide pixels: the stage scales the whole slide, offsetWidth is unscaled.
|
|
73
|
+
const [measure, setMeasure] = useState({ width: 0, font: 'sans-serif', fontsReady: 0 })
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
const element = ref.current
|
|
76
|
+
if (!element) return
|
|
77
|
+
const read = () => setMeasure(current => ({ ...current, width: element.offsetWidth, font: getComputedStyle(element).fontFamily || 'sans-serif' }))
|
|
78
|
+
read()
|
|
79
|
+
// The theme's font must be there before letters are measured.
|
|
80
|
+
document.fonts?.ready.then(() => setMeasure(current => ({ ...current, fontsReady: current.fontsReady + 1 })))
|
|
81
|
+
const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(read) : null
|
|
82
|
+
observer?.observe(element)
|
|
83
|
+
return () => observer?.disconnect()
|
|
84
|
+
}, [])
|
|
85
|
+
const key = words.map(word => `${word.text}:${word.n}`).join('|')
|
|
86
|
+
const placed = useMemo(() => layout(words, { width: measure.width, height: cloudHeight, font: measure.font }), [key, measure.width, measure.font, measure.fontsReady, cloudHeight])
|
|
87
|
+
const code = wantsQr(qr)
|
|
88
|
+
return <div class="poll poll--cloud">
|
|
89
|
+
<div class="poll-main">
|
|
90
|
+
{question && <p class="poll-question">{question}</p>}
|
|
91
|
+
{/* Without a server and words there is nothing to place: no empty box. */}
|
|
92
|
+
<div class="word-cloud" ref={ref} style={{ height: live.offline && !words.length ? 0 : `${cloudHeight}px` }}>
|
|
93
|
+
{words.length
|
|
94
|
+
? <svg viewBox={`${-measure.width / 2} ${-cloudHeight / 2} ${measure.width || 1} ${cloudHeight}`} width="100%" height="100%" role="img" aria-label={words.map(word => word.text).join(', ')}>
|
|
95
|
+
{placed.map(word => <text key={word.text} class={`word word--${word.text === words[0].text ? 0 : hash(word.text) % 6}`} text-anchor="middle"
|
|
96
|
+
style={{ fontSize: `${word.size}px`, fontFamily: measure.font, transform: `translate(${word.x}px, ${word.y}px) rotate(${word.rotate}deg)` }}>{word.text}</text>)}
|
|
97
|
+
</svg>
|
|
98
|
+
: !live.offline && <p class="question-empty">{t('question.empty')}</p>}
|
|
99
|
+
</div>
|
|
100
|
+
<ActivityFooter count={total} {...live} />
|
|
101
|
+
</div>
|
|
102
|
+
<JoinCode room={room} {...live} qr={code} size={240} />
|
|
103
|
+
</div>
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
WordCloud.phone = ({ question, placeholder }, { slideTitle = '' } = {}) => ({ type: 'text', question: question || slideTitle, placeholder: placeholder ?? '', maxLength: 40 })
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { h } from 'preact'
|
|
2
|
+
import QrCode from './QrCode.jsx'
|
|
3
|
+
import { t } from '../core/labels.js'
|
|
4
|
+
|
|
5
|
+
// Parts every activity on a slide shares (<poll>, <question>, <wordcloud>,
|
|
6
|
+
// <scale>, and <qrcode join />): the deck's join code and the line with the number of
|
|
7
|
+
// answers, the live dot and Reset.
|
|
8
|
+
|
|
9
|
+
export const shortLink = url => url.replace(/^https?:\/\//, '')
|
|
10
|
+
// `qr="false"` (or "no", "off") leaves the code out: it was on an earlier
|
|
11
|
+
// slide (<qrcode join />), and people's phones follow along.
|
|
12
|
+
export const wantsQr = qr => !/^(false|no|off|0)$/i.test(String(qr ?? 'true'))
|
|
13
|
+
|
|
14
|
+
/** The join code with the short link, or how to make phones reach the server. */
|
|
15
|
+
export function JoinCode({ room, joinUrl, localJoinUrl, known, offline, qr = true, size = 300, label = t('poll.scan') }) {
|
|
16
|
+
if (!qr || offline || (!known && !joinUrl)) return null
|
|
17
|
+
if (!joinUrl) return <p class="poll-join poll-join--local">{t('poll.unreachable', { command: <code>mdeck run --network</code>, setting: <code>server</code> })} <a href={localJoinUrl} target="_blank" rel="noopener">{t('poll.tryHere')}</a></p>
|
|
18
|
+
return <a class="poll-join" href={joinUrl} target="_blank" rel="noopener" data-room={room}>
|
|
19
|
+
<QrCode url={joinUrl} size={String(size)} />
|
|
20
|
+
<span>{label}</span>
|
|
21
|
+
<span class="poll-link">{shortLink(joinUrl)}</span>
|
|
22
|
+
</a>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Number of answers, whether the room is live, and Reset for the presenter; `children` go at the end. */
|
|
26
|
+
export function ActivityFooter({ count, connected, canReset, reset, offline, children }) {
|
|
27
|
+
// Without a server this is a record of the talk: no count, dot or buttons.
|
|
28
|
+
if (offline && !count) return <p class="poll-total poll-static">{t('poll.static')}</p>
|
|
29
|
+
return <p class="poll-total">
|
|
30
|
+
<span class={`poll-dot${connected ? ' is-live' : ''}`} title={connected ? t('poll.live') : t('poll.offline')} />
|
|
31
|
+
{count === 1 ? t('poll.answer') : t('poll.answers', { n: count })}
|
|
32
|
+
{canReset && <button class="poll-reset" disabled={count === 0} onClick={() => reset().catch(() => {})}>{t('poll.reset')}</button>}
|
|
33
|
+
{children}
|
|
34
|
+
</p>
|
|
35
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--callout-note: #0969da;
|
|
3
|
+
--callout-tip: #1a7f37;
|
|
4
|
+
--callout-important: #8250df;
|
|
5
|
+
--callout-warning: #eab308;
|
|
6
|
+
--callout-caution: #cf222e;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.callout {
|
|
10
|
+
border-left: 4px solid var(--rule);
|
|
11
|
+
padding: 20px 28px;
|
|
12
|
+
background: var(--surface, var(--bg));
|
|
13
|
+
margin: var(--gap-item) 0;
|
|
14
|
+
}
|
|
15
|
+
.callout-title {
|
|
16
|
+
font-weight: 600;
|
|
17
|
+
font-size: var(--fs-eyebrow);
|
|
18
|
+
letter-spacing: 0.06em;
|
|
19
|
+
text-transform: uppercase;
|
|
20
|
+
margin: 0 0 8px 0;
|
|
21
|
+
}
|
|
22
|
+
.callout p { margin: 0; color: var(--ink-soft); padding-left: 28px; }
|
|
23
|
+
|
|
24
|
+
.callout-note { border-left-color: var(--callout-note); }
|
|
25
|
+
.callout-tip { border-left-color: var(--callout-tip); }
|
|
26
|
+
.callout-important { border-left-color: var(--callout-important); }
|
|
27
|
+
.callout-warning { border-left-color: var(--callout-warning); }
|
|
28
|
+
.callout-caution { border-left-color: var(--callout-caution); }
|
|
29
|
+
|
|
30
|
+
/* Titles are text: the signal colour mixed with the text colour, darker on
|
|
31
|
+
light slides and lighter on dark ones, so they can be read on both. The
|
|
32
|
+
yellow of a warning needs more of the text colour. */
|
|
33
|
+
.callout-note .callout-title { color: color-mix(in oklab, var(--callout-note) 65%, var(--ink)); }
|
|
34
|
+
.callout-tip .callout-title { color: color-mix(in oklab, var(--callout-tip) 65%, var(--ink)); }
|
|
35
|
+
.callout-important .callout-title { color: color-mix(in oklab, var(--callout-important) 65%, var(--ink)); }
|
|
36
|
+
.callout-warning .callout-title { color: color-mix(in oklab, var(--callout-warning) 45%, var(--ink)); }
|
|
37
|
+
.callout-caution .callout-title { color: color-mix(in oklab, var(--callout-caution) 65%, var(--ink)); }
|
|
38
|
+
|
|
39
|
+
/* Blocks for lectures, in every theme. Theorems, lemmas and corollaries in
|
|
40
|
+
the accent with the statement in italics, definitions in the second
|
|
41
|
+
accent; examples and remarks stay plain. A proof is no box: "Proof." runs
|
|
42
|
+
into its text, and a square ends it. Themes with their own callout style
|
|
43
|
+
colour these blocks their way; the proof is shared, and themes that set
|
|
44
|
+
it differently (academic, sketch) use the same selectors. */
|
|
45
|
+
.callout:is(.callout-theorem, .callout-lemma, .callout-corollary) { border-left-color: var(--accent); }
|
|
46
|
+
.callout.callout-definition { border-left-color: var(--accent-2); }
|
|
47
|
+
.callout:is(.callout-theorem, .callout-lemma, .callout-corollary) .callout-title { color: color-mix(in oklab, var(--accent) 75%, var(--ink)); }
|
|
48
|
+
.callout.callout-definition .callout-title { color: color-mix(in oklab, var(--accent-2) 75%, var(--ink)); }
|
|
49
|
+
.callout:is(.callout-theorem, .callout-lemma, .callout-corollary) > :not(.callout-title) { font-style: italic; }
|
|
50
|
+
|
|
51
|
+
.slide-body .callout.callout-proof {
|
|
52
|
+
display: block;
|
|
53
|
+
background: none;
|
|
54
|
+
border: 0;
|
|
55
|
+
border-radius: 0;
|
|
56
|
+
box-shadow: none;
|
|
57
|
+
padding: 0;
|
|
58
|
+
max-width: none;
|
|
59
|
+
}
|
|
60
|
+
.slide-body .callout.callout-proof::before { display: none; }
|
|
61
|
+
.slide-body .callout.callout-proof .callout-title {
|
|
62
|
+
display: inline;
|
|
63
|
+
float: left;
|
|
64
|
+
margin: 0 0.4em 0 0;
|
|
65
|
+
padding: 0;
|
|
66
|
+
background: none;
|
|
67
|
+
font-size: inherit;
|
|
68
|
+
font-style: italic;
|
|
69
|
+
font-weight: 700;
|
|
70
|
+
line-height: inherit;
|
|
71
|
+
letter-spacing: 0;
|
|
72
|
+
text-transform: none;
|
|
73
|
+
color: var(--ink);
|
|
74
|
+
}
|
|
75
|
+
.slide-body .callout.callout-proof .callout-title::before { content: none; }
|
|
76
|
+
.slide-body .callout.callout-proof .callout-title::after { content: '.'; }
|
|
77
|
+
.slide-body .callout.callout-proof > :not(.callout-title) { padding-left: 0; font-style: normal; }
|
|
78
|
+
.slide-body .callout.callout-proof > :last-child::after { content: '\220E'; margin-left: 0.6em; color: var(--ink); }
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--token-default: #0a0a0a;
|
|
3
|
+
--token-comment: #708090;
|
|
4
|
+
--token-string: #669900;
|
|
5
|
+
--token-number: #905050;
|
|
6
|
+
--token-keyword: #0077aa;
|
|
7
|
+
--token-function: #dd4a68;
|
|
8
|
+
--token-operator: #9a6e3a;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
code[class*="language-"] { color: var(--token-default); }
|
|
12
|
+
.token.comment, .token.prolog, .token.doctype, .token.cdata { color: var(--token-comment); font-style: italic; }
|
|
13
|
+
.token.punctuation { color: var(--token-default); opacity: 0.7; }
|
|
14
|
+
.token.property, .token.tag, .token.boolean, .token.number, .token.constant, .token.symbol, .token.deleted { color: var(--token-number); }
|
|
15
|
+
.token.selector, .token.attr-name, .token.string, .token.char, .token.builtin, .token.inserted { color: var(--token-string); }
|
|
16
|
+
.token.operator, .token.entity, .token.url, .token.regex, .token.variable { color: var(--token-operator); }
|
|
17
|
+
.token.atrule, .token.attr-value, .token.keyword { color: var(--token-keyword); }
|
|
18
|
+
.token.function, .token.class-name { color: var(--token-function); }
|
|
19
|
+
.token.important, .token.bold { font-weight: bold; }
|
|
20
|
+
.token.italic { font-style: italic; }
|
|
21
|
+
|
|
22
|
+
/* ── Wrapper ────────────────────────────────────────────────────────────── */
|
|
23
|
+
.code-block-wrapper {
|
|
24
|
+
position: relative;
|
|
25
|
+
margin: 40px 0;
|
|
26
|
+
width: 100%;
|
|
27
|
+
/* Centred layouts (focus, title) must not centre the lines of code. */
|
|
28
|
+
text-align: left;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/* ── Action buttons ─────────────────────────────────────────────────────── */
|
|
32
|
+
.code-block-actions {
|
|
33
|
+
display: flex;
|
|
34
|
+
justify-content: flex-end;
|
|
35
|
+
gap: 8px;
|
|
36
|
+
padding: 8px 12px;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.code-btn {
|
|
40
|
+
display: inline-flex;
|
|
41
|
+
align-items: center;
|
|
42
|
+
justify-content: center;
|
|
43
|
+
width: 36px;
|
|
44
|
+
height: 36px;
|
|
45
|
+
background: var(--bg, #fff);
|
|
46
|
+
color: var(--ink, #0a0a0a);
|
|
47
|
+
border: 1.5px solid var(--rule, #e5e5e5);
|
|
48
|
+
border-radius: 6px;
|
|
49
|
+
cursor: pointer;
|
|
50
|
+
transition: background 0.1s;
|
|
51
|
+
}
|
|
52
|
+
.code-btn:hover { background: var(--rule, #e5e5e5); }
|
|
53
|
+
.code-btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
54
|
+
.code-btn-spin { animation: code-btn-spin 0.9s linear infinite; }
|
|
55
|
+
@keyframes code-btn-spin { to { transform: rotate(360deg); } }
|
|
56
|
+
@media (prefers-reduced-motion: reduce) { .code-btn-spin { animation: none; } }
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
/* ── Editable: highlighted pre + transparent textarea overlay ───────────── */
|
|
60
|
+
.code-editable-container {
|
|
61
|
+
position: relative;
|
|
62
|
+
}
|
|
63
|
+
.code-editable-highlight {
|
|
64
|
+
overflow: hidden;
|
|
65
|
+
pointer-events: none;
|
|
66
|
+
user-select: none;
|
|
67
|
+
margin: 0;
|
|
68
|
+
}
|
|
69
|
+
/* The textarea sits on top; .code-block class makes it inherit the theme's
|
|
70
|
+
padding/line-height/font so cursor positions align exactly with the highlight layer.
|
|
71
|
+
The double-class selector overrides any single-class .code-block theme rules. */
|
|
72
|
+
.code-block.code-editable-input {
|
|
73
|
+
position: absolute;
|
|
74
|
+
inset: 0;
|
|
75
|
+
background: transparent;
|
|
76
|
+
color: transparent;
|
|
77
|
+
caret-color: var(--ink, #0a0a0a);
|
|
78
|
+
border-color: transparent; /* keep border width for layout alignment, hide color */
|
|
79
|
+
box-shadow: none;
|
|
80
|
+
resize: none;
|
|
81
|
+
overflow: hidden;
|
|
82
|
+
outline: none;
|
|
83
|
+
tab-size: 2;
|
|
84
|
+
}
|
|
85
|
+
.code-block.code-editable-input::before { display: none; }
|
|
86
|
+
.code-block.code-editable-input::selection {
|
|
87
|
+
color: transparent;
|
|
88
|
+
background-color: rgba(100, 150, 255, 0.25);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* ── Output area ────────────────────────────────────────────────────────── */
|
|
92
|
+
.code-output {
|
|
93
|
+
margin: 0;
|
|
94
|
+
padding: 28px 48px;
|
|
95
|
+
background: var(--surface, var(--rule));
|
|
96
|
+
color: var(--ink);
|
|
97
|
+
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
|
98
|
+
font-size: var(--code-size, 30px);
|
|
99
|
+
line-height: 1.55;
|
|
100
|
+
white-space: pre;
|
|
101
|
+
/* The height includes the padding, as the fitted height does (fit.js). */
|
|
102
|
+
box-sizing: border-box;
|
|
103
|
+
max-height: 10em;
|
|
104
|
+
overflow-y: auto;
|
|
105
|
+
border-radius: 0 8px 8px 8px;
|
|
106
|
+
border-left: 4px solid var(--rule);
|
|
107
|
+
}
|
|
108
|
+
.code-output--error {
|
|
109
|
+
color: #f87171;
|
|
110
|
+
border-left-color: #ef4444;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* ── Code taller than its place on the slide (src/runtime/fit.js) ───────── */
|
|
114
|
+
/* The runtime caps the height; the code scrolls inside. For editable code
|
|
115
|
+
the text field scrolls, and the highlighted layer follows it. */
|
|
116
|
+
/* The capped height is the whole box, padding and rules included. */
|
|
117
|
+
.code-block.is-capped { box-sizing: border-box; }
|
|
118
|
+
pre.code-block.is-capped { overflow-y: auto; }
|
|
119
|
+
.code-block.code-editable-highlight.is-capped { overflow: hidden; }
|
|
120
|
+
.code-editable-container.is-capped .code-block.code-editable-input { overflow-y: auto; }
|
|
121
|
+
/* While there is more below, the lower edge fades: the audience sees that
|
|
122
|
+
the code goes on. */
|
|
123
|
+
:is(.code-block, .code-output).is-capped:not(.is-at-end) { -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 72px), transparent); mask-image: linear-gradient(to bottom, #000 calc(100% - 72px), transparent); }
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/* ── Poll on the slide ─────────────────────────────────────────────────── */
|
|
2
|
+
.poll { display: flex; gap: 72px; align-items: center; width: 100%; font-size: var(--fs-body, 36px); color: var(--ink, #111); }
|
|
3
|
+
.poll-main { flex: 1; min-width: 0; }
|
|
4
|
+
.poll .poll-question { margin: 0 0 32px; font-weight: 600; }
|
|
5
|
+
.poll-bars { display: grid; gap: 20px; }
|
|
6
|
+
.poll-row { display: grid; grid-template-columns: minmax(120px, 28%) 1fr 3ch; gap: 24px; align-items: center; }
|
|
7
|
+
.poll-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
8
|
+
.poll-track { height: 1.1em; background: color-mix(in srgb, var(--ink, #111) 8%, transparent); border-radius: 6px; overflow: hidden; }
|
|
9
|
+
.poll-fill { display: block; height: 100%; background: var(--accent, #0d9488); border-radius: 6px; transition: width .6s cubic-bezier(.2, .8, .2, 1); }
|
|
10
|
+
.poll-count { text-align: right; font-variant-numeric: tabular-nums; font-weight: 600; }
|
|
11
|
+
.poll .poll-total { display: flex; align-items: center; gap: 14px; margin: 36px 0 0; font-size: var(--fs-small, 24px); color: var(--muted, #666); }
|
|
12
|
+
.poll-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--muted, #999); opacity: .5; }
|
|
13
|
+
.poll-dot.is-live { background: #16a34a; opacity: 1; }
|
|
14
|
+
.poll-reset { margin-left: auto; padding: 4px 14px; border: 1px solid var(--rule, #ccc); border-radius: 6px; background: none; color: inherit; font: inherit; cursor: pointer; }
|
|
15
|
+
.poll-reset:disabled { opacity: .4; cursor: default; }
|
|
16
|
+
/* The audience sees the results, not the presenter's controls. */
|
|
17
|
+
deck-stage[data-follower] .poll-reset { display: none; }
|
|
18
|
+
/* answer="…": the tick outlines the right answer's row (label, bar, count) in green. */
|
|
19
|
+
.poll-solve { display: grid; place-items: center; width: 44px; height: 44px; margin-left: auto; padding: 0; border: 1px solid var(--rule, #ccc); border-radius: 50%; background: none; color: var(--muted, #666); cursor: pointer; opacity: .55; transition: opacity .2s, background .2s, color .2s; }
|
|
20
|
+
.poll-reset + .poll-solve { margin-left: 0; }
|
|
21
|
+
.poll-solve svg { width: 24px; height: 24px; }
|
|
22
|
+
.poll-solve:hover, .poll-solve:focus-visible { opacity: 1; }
|
|
23
|
+
.poll-solve.is-on { opacity: 1; background: var(--poll-correct, #16a34a); border-color: var(--poll-correct, #16a34a); color: #fff; }
|
|
24
|
+
deck-stage[data-follower] .poll-solve { display: none; }
|
|
25
|
+
.poll.is-solved .poll-row.is-correct { outline: 4px solid var(--poll-correct, #16a34a); outline-offset: 10px; border-radius: 10px; }
|
|
26
|
+
.poll.is-solved .poll-row.is-correct .poll-label { font-weight: 700; color: var(--poll-correct, #16a34a); }
|
|
27
|
+
.poll-link { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 20px; color: var(--muted, #555); }
|
|
28
|
+
/* The join code in the slide's own colours (see QrCode.jsx). */
|
|
29
|
+
.poll-join { flex: none; display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 18px; border: 1px solid var(--rule, rgba(0,0,0,.12)); border-radius: 12px; color: var(--ink, #111); text-decoration: none; font-size: var(--fs-small, 24px); }
|
|
30
|
+
.poll-join--local { display: block; max-width: 460px; text-align: left; line-height: 1.4; color: var(--ink-soft, var(--ink, #333)); }
|
|
31
|
+
.poll-join--local code { display: inline; font-size: .9em; }
|
|
32
|
+
.poll-join--local a { color: var(--accent, #0d9488); }
|
|
33
|
+
|
|
34
|
+
/* <question>: the newest answers as cards */
|
|
35
|
+
.question-cards { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 18px; }
|
|
36
|
+
.slide-body .question-cards li::before, .question-cards li::before, .question-cards li::marker { content: none; display: none; }
|
|
37
|
+
.slide-body .question-cards li { padding-left: 22px; }
|
|
38
|
+
.question-cards li { margin: 0; padding: 18px 22px; border-radius: 10px; background: var(--surface, color-mix(in srgb, var(--ink, #111) 5%, transparent)); border-left: 5px solid var(--accent, #0d9488); font-size: .8em; line-height: 1.35; overflow-wrap: anywhere; animation: poll-in .4s ease both; }
|
|
39
|
+
.question-cards li:first-child { border-left-color: var(--accent-2, var(--accent, #0d9488)); }
|
|
40
|
+
.question-empty { margin: 0; color: var(--muted, #666); font-style: italic; }
|
|
41
|
+
@keyframes poll-in { from { opacity: 0; transform: translateY(8px); } }
|
|
42
|
+
|
|
43
|
+
/* <wordcloud>: SVG text placed by d3-cloud */
|
|
44
|
+
.word-cloud { position: relative; width: 100%; }
|
|
45
|
+
.word-cloud svg { display: block; overflow: visible; }
|
|
46
|
+
.word { font-weight: 700; transition: transform .7s cubic-bezier(.2, .8, .2, 1), font-size .7s cubic-bezier(.2, .8, .2, 1); }
|
|
47
|
+
/* Six colours from the palette: its accents and inks, and two blends of
|
|
48
|
+
them, all readable on the slide. A theme or deck may set --cloud-1…6. */
|
|
49
|
+
.word--0 { fill: var(--cloud-1, var(--accent, #0d9488)); }
|
|
50
|
+
.word--1 { fill: var(--cloud-2, var(--accent-2, #7c3aed)); }
|
|
51
|
+
.word--2 { fill: var(--cloud-3, var(--ink, #111)); }
|
|
52
|
+
.word--3 { fill: var(--cloud-4, color-mix(in oklab, var(--accent, #0d9488) 50%, var(--accent-2, #7c3aed))); }
|
|
53
|
+
.word--4 { fill: var(--cloud-5, var(--ink-soft, #444)); }
|
|
54
|
+
.word--5 { fill: var(--cloud-6, color-mix(in oklab, var(--accent, #0d9488) 55%, var(--ink, #111))); }
|
|
55
|
+
|
|
56
|
+
/* <scale> */
|
|
57
|
+
.scale-columns { display: flex; gap: 20px; align-items: stretch; height: 320px; }
|
|
58
|
+
.scale-column { flex: 1; display: grid; grid-template-rows: auto 1fr auto; gap: 10px; justify-items: center; }
|
|
59
|
+
.scale-track { width: 100%; display: flex; align-items: flex-end; background: color-mix(in srgb, var(--ink, #111) 6%, transparent); border-radius: 6px; overflow: hidden; }
|
|
60
|
+
.scale-fill { display: block; width: 100%; background: var(--accent, #0d9488); border-radius: 6px 6px 0 0; transition: height .6s cubic-bezier(.2, .8, .2, 1); }
|
|
61
|
+
.scale-count { font-variant-numeric: tabular-nums; font-weight: 600; font-size: var(--fs-small, 24px); }
|
|
62
|
+
.scale-step { font-variant-numeric: tabular-nums; color: var(--muted, #666); }
|
|
63
|
+
.poll .scale-labels { display: flex; justify-content: space-between; margin: 12px 0 0; font-size: var(--fs-small, 24px); color: var(--muted, #666); }
|
|
64
|
+
.poll .scale-average { margin: 10px 0 0; font-size: var(--fs-small, 24px); font-weight: 600; }
|
|
65
|
+
|
|
66
|
+
/* <qrcode join />: the join code, large, on a slide of its own */
|
|
67
|
+
.poll-join-slide { display: flex; flex-direction: column; align-items: center; gap: 20px; width: 100%; }
|
|
68
|
+
.poll-join-slide .poll-join { font-size: var(--fs-body, 32px); gap: 16px; padding: 24px 32px; }
|
|
69
|
+
.poll-join-slide .poll-link { font-size: 28px; }
|
|
70
|
+
@media print { .poll-reset, .poll-dot, .poll-solve { display: none; } .poll-fill, .scale-fill, .word { transition: none; animation: none; } .question-cards li { animation: none; } }
|
|
71
|
+
|
|
72
|
+
/* A record of the talk (no room server: a PDF, a file sent to readers): the
|
|
73
|
+
question and its options, without counts, the live dot or a join code. */
|
|
74
|
+
.poll.is-static .poll-count, .poll.is-static .scale-count { visibility: hidden; }
|
|
75
|
+
.poll .poll-static, .poll-join-slide .poll-static { margin: 36px 0 0; color: var(--muted, #666); font-style: italic; font-size: var(--fs-small, 24px); }
|
|
76
|
+
.poll-join-slide .poll-static { margin: 0; font-size: var(--fs-body, 32px); }
|
|
77
|
+
|
|
78
|
+
/* On paper the join code and the presenter's controls have no use. */
|
|
79
|
+
@media print {
|
|
80
|
+
.poll-join, .poll-reset, .poll-solve, .poll-dot { display: none !important; }
|
|
81
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
.video-player-container {
|
|
2
|
+
position: relative;
|
|
3
|
+
width: 100%;
|
|
4
|
+
max-height: 800px;
|
|
5
|
+
background: #000;
|
|
6
|
+
border-radius: var(--radius, 4px);
|
|
7
|
+
overflow: hidden;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.video-player-placeholder {
|
|
11
|
+
width: 100%;
|
|
12
|
+
height: 100%;
|
|
13
|
+
background: #111;
|
|
14
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// The dev server's own addresses (/__mdeck/…) for pages it serves. With
|
|
2
|
+
// `mdeck run --server` it serves at /t/<id>/ instead of /, so they follow
|
|
3
|
+
// Vite's base path; built pages and Node tests have no dev server and use /.
|
|
4
|
+
export const devPath = path => `${import.meta.env?.DEV ? (import.meta.env.BASE_URL ?? '/') : '/'}${path.replace(/^\//, '')}`
|