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,148 @@
|
|
|
1
|
+
/* Launch page for `mdeck run`. It is a tool page like the presenter view, the
|
|
2
|
+
editor and the reader's bar, and shares their dark chrome: neutral greys,
|
|
3
|
+
small uppercase labels, grey buttons and the editor's blue for the main
|
|
4
|
+
action. The teal of the mdeck mark stays in the mark. */
|
|
5
|
+
:root {
|
|
6
|
+
--home-bg: #111;
|
|
7
|
+
--home-bar: #161616;
|
|
8
|
+
--home-panel: #171717;
|
|
9
|
+
--home-raised: #1e1e1e;
|
|
10
|
+
--home-line: #262626;
|
|
11
|
+
--home-control: #3a3a3a;
|
|
12
|
+
--home-ink: #e6e6e6;
|
|
13
|
+
--home-strong: #fff;
|
|
14
|
+
--home-muted: #a0a0a0;
|
|
15
|
+
--home-faint: #6b6b6b;
|
|
16
|
+
--home-button: #222;
|
|
17
|
+
--home-primary: #27436a;
|
|
18
|
+
--home-primary-line: #3d6094;
|
|
19
|
+
--home-primary-ink: #e8f0ff;
|
|
20
|
+
--home-selected: #23384f;
|
|
21
|
+
--home-focus: rgba(138, 180, 248, 0.5);
|
|
22
|
+
--home-ok: #81c995;
|
|
23
|
+
--home-warn: #e3c36d;
|
|
24
|
+
--home-warn-soft: #2e2715;
|
|
25
|
+
--home-bad: #f28b82;
|
|
26
|
+
--home-bad-soft: #3a1d1b;
|
|
27
|
+
--home-radius: 6px;
|
|
28
|
+
color-scheme: dark;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
* { box-sizing: border-box; }
|
|
32
|
+
html, body { margin: 0; background: var(--home-bg); color: var(--home-ink); }
|
|
33
|
+
body { font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; -webkit-font-smoothing: antialiased; }
|
|
34
|
+
code, pre, kbd { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 0.88em; }
|
|
35
|
+
a { color: inherit; }
|
|
36
|
+
h1, h2, h3 { margin: 0; line-height: 1.25; }
|
|
37
|
+
button:focus-visible, a:focus-visible { outline: 2px solid var(--home-focus); outline-offset: 1px; }
|
|
38
|
+
|
|
39
|
+
.home { padding: 0 0 28px; }
|
|
40
|
+
.home > :not(.home-header) { max-width: 1180px; margin-left: auto; margin-right: auto; padding-left: 20px; padding-right: 20px; }
|
|
41
|
+
.home--empty { min-height: 100vh; display: grid; place-content: center; justify-items: center; text-align: center; color: var(--home-muted); }
|
|
42
|
+
|
|
43
|
+
/* ── Top bar, like the editor's and the reader's ─────────────────────── */
|
|
44
|
+
.home-header { position: sticky; top: 0; z-index: 2; display: flex; align-items: center; gap: 12px; margin-bottom: 20px; padding: 10px 20px; background: var(--home-bar); border-bottom: 1px solid var(--home-line); }
|
|
45
|
+
.home-mark { flex: none; border-radius: 6px; }
|
|
46
|
+
.home-heading { flex: 1; min-width: 0; display: flex; align-items: baseline; gap: 4px 12px; flex-wrap: wrap; }
|
|
47
|
+
.home-heading h1 { font-size: 15px; font-weight: 600; color: var(--home-strong); }
|
|
48
|
+
.home-meta { margin: 0; color: var(--home-muted); font-size: 13px; }
|
|
49
|
+
.home-file { margin: 0; color: var(--home-faint); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; flex-basis: 100%; }
|
|
50
|
+
.home-health { flex: none; display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border: 1px solid #2c4a35; border-radius: 999px; color: var(--home-ok); text-decoration: none; font-size: 12px; }
|
|
51
|
+
.home-health.is-warn { border-color: #5a4a1a; color: var(--home-warn); }
|
|
52
|
+
.home-health.is-bad { border-color: #5c2b28; color: var(--home-bad); }
|
|
53
|
+
|
|
54
|
+
.home > .home-error { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 16px; padding: 10px 14px; border: 1px solid #5c2b28; border-radius: var(--home-radius); background: var(--home-bad-soft); color: var(--home-bad); }
|
|
55
|
+
|
|
56
|
+
/* ── Sections ─────────────────────────────────────────────────────────── */
|
|
57
|
+
.home-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
|
58
|
+
.home-section { background: var(--home-panel); border: 1px solid var(--home-line); border-radius: 8px; padding: 14px; min-width: 0; }
|
|
59
|
+
.home-section--wide { grid-column: 1 / -1; }
|
|
60
|
+
.home-section h2 { font-size: 11px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.08em; color: var(--home-muted); margin-bottom: 10px; }
|
|
61
|
+
.home-section-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
|
|
62
|
+
.home-section-head h2 { margin: 0; }
|
|
63
|
+
|
|
64
|
+
/* ── Deck preview ─────────────────────────────────────────────────────── */
|
|
65
|
+
.home-preview { display: block; position: relative; overflow: hidden; border: 1px solid var(--home-line); border-radius: var(--home-radius); background: #000; }
|
|
66
|
+
.home-preview iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; pointer-events: none; }
|
|
67
|
+
.home-preview-open { position: absolute; inset: 0; }
|
|
68
|
+
.home-preview-bar { display: flex; align-items: center; gap: 6px; margin-top: 8px; }
|
|
69
|
+
.home-preview-count { color: var(--home-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
|
|
70
|
+
.home-spacer { flex: 1; }
|
|
71
|
+
|
|
72
|
+
/* ── Tiles ────────────────────────────────────────────────────────────── */
|
|
73
|
+
.home-tiles { display: grid; gap: 6px; }
|
|
74
|
+
.home-tile { display: flex; align-items: center; gap: 12px; width: 100%; padding: 8px 10px; border: 1px solid var(--home-line); border-radius: var(--home-radius); background: var(--home-raised); color: inherit; font: inherit; text-align: left; text-decoration: none; cursor: pointer; }
|
|
75
|
+
.home-tile:hover { border-color: var(--home-control); background: #232323; }
|
|
76
|
+
.home-tile:disabled { cursor: progress; }
|
|
77
|
+
.home-tile > .home-icon { flex: none; color: var(--home-faint); }
|
|
78
|
+
.home-tile-icon { flex: none; display: grid; place-items: center; width: 32px; height: 32px; border-radius: var(--home-radius); background: var(--home-button); color: var(--home-muted); }
|
|
79
|
+
.home-tile-body { flex: 1; min-width: 0; display: grid; }
|
|
80
|
+
.home-tile-body strong { font-weight: 600; color: var(--home-strong); font-size: 13px; }
|
|
81
|
+
.home-tile-body span { color: var(--home-muted); font-size: 12px; overflow-wrap: anywhere; }
|
|
82
|
+
|
|
83
|
+
/* ── Share ────────────────────────────────────────────────────────────── */
|
|
84
|
+
.home-outputs { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
|
|
85
|
+
.home-output { display: flex; gap: 12px; align-items: center; padding: 8px 10px; border: 1px solid var(--home-line); border-radius: var(--home-radius); background: var(--home-raised); }
|
|
86
|
+
.home-output-body { flex: 1; min-width: 0; display: grid; }
|
|
87
|
+
.home-output-body strong { font-size: 13px; color: var(--home-strong); }
|
|
88
|
+
.home-output-body > span { color: var(--home-muted); font-size: 12px; }
|
|
89
|
+
.home-output-body code { color: var(--home-faint); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
90
|
+
.home-output-body details { margin-top: 6px; font-size: 12px; }
|
|
91
|
+
.home-output-body pre { max-height: 220px; overflow: auto; padding: 8px; border-radius: 4px; background: var(--home-bg); white-space: pre-wrap; }
|
|
92
|
+
.home-note { color: var(--home-warn) !important; }
|
|
93
|
+
.home-relay--up { color: var(--home-ok) !important; }
|
|
94
|
+
.home-relay--refused { color: var(--home-bad) !important; }
|
|
95
|
+
.home-output-actions { flex: none; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
|
|
96
|
+
.home-status { font-size: 12px; color: var(--home-faint); min-width: 90px; text-align: right; }
|
|
97
|
+
.home-status--done { color: var(--home-ok); }
|
|
98
|
+
.home-status--failed { color: var(--home-bad); }
|
|
99
|
+
.home-status--running { color: var(--home-warn); }
|
|
100
|
+
|
|
101
|
+
/* ── Buttons, as in the editor ────────────────────────────────────────── */
|
|
102
|
+
.home-btn { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border: 1px solid var(--home-control); border-radius: 5px; background: var(--home-button); color: var(--home-ink); font: inherit; font-size: 13px; line-height: 1.4; cursor: pointer; text-decoration: none; }
|
|
103
|
+
.home-btn:hover:not(:disabled) { border-color: #5a5a5a; color: #fff; }
|
|
104
|
+
.home-btn:disabled { opacity: .45; cursor: default; }
|
|
105
|
+
.home-btn--primary { background: var(--home-primary); border-color: var(--home-primary-line); color: var(--home-primary-ink); }
|
|
106
|
+
.home-btn--small { padding: 2px 8px; font-size: 12px; }
|
|
107
|
+
.home-link { border: 0; background: none; padding: 0; font: inherit; color: var(--home-muted); cursor: pointer; text-decoration: underline; }
|
|
108
|
+
.home-link:hover { color: var(--home-strong); }
|
|
109
|
+
|
|
110
|
+
/* ── Check ────────────────────────────────────────────────────────────── */
|
|
111
|
+
.home-ok { display: flex; align-items: center; gap: 8px; margin: 0; color: var(--home-ok); font-size: 13px; }
|
|
112
|
+
.home-diagnostics { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }
|
|
113
|
+
.home-diagnostic { display: flex; gap: 10px; align-items: baseline; padding: 6px 10px; border-radius: 4px; background: var(--home-warn-soft); font-size: 13px; }
|
|
114
|
+
.home-diagnostic--error { background: var(--home-bad-soft); }
|
|
115
|
+
.home-badge { flex: none; font-size: 10px; text-transform: uppercase; letter-spacing: .08em; color: var(--home-warn); }
|
|
116
|
+
.home-diagnostic--error .home-badge { color: var(--home-bad); }
|
|
117
|
+
.home-line { margin-left: auto; flex: none; color: var(--home-faint); font-size: 12px; }
|
|
118
|
+
|
|
119
|
+
/* ── Live polls ───────────────────────────────────────────────────────── */
|
|
120
|
+
.home-live { display: grid; gap: 10px; margin: 0; }
|
|
121
|
+
.home-live > div { display: grid; grid-template-columns: 150px minmax(0, 1fr); gap: 12px; align-items: baseline; }
|
|
122
|
+
.home-live dt { color: var(--home-muted); font-size: 12px; }
|
|
123
|
+
.home-live dd { margin: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; min-width: 0; font-size: 13px; }
|
|
124
|
+
.home-live dd > p { flex-basis: 100%; margin: 0; color: var(--home-muted); font-size: 12px; }
|
|
125
|
+
.home-live code { overflow-wrap: anywhere; }
|
|
126
|
+
.home-live a { color: var(--home-ink); }
|
|
127
|
+
.home-code { padding: 1px 6px; border: 1px solid var(--home-control); border-radius: 4px; background: var(--home-button); color: var(--home-strong); }
|
|
128
|
+
.home-pill { display: inline-block; padding: 0 8px; border: 1px solid var(--home-control); border-radius: 999px; font-size: 11px; color: var(--home-muted); }
|
|
129
|
+
.home-pill.is-ok { border-color: #2c4a35; color: var(--home-ok); }
|
|
130
|
+
.home-pill.is-bad { border-color: #5c2b28; color: var(--home-bad); }
|
|
131
|
+
|
|
132
|
+
@media (max-width: 760px) {
|
|
133
|
+
.home > :not(.home-header) { padding-left: 12px; padding-right: 12px; }
|
|
134
|
+
.home-header { margin-bottom: 14px; padding: 10px 12px; }
|
|
135
|
+
.home-grid { grid-template-columns: minmax(0, 1fr); }
|
|
136
|
+
.home-output { flex-wrap: wrap; }
|
|
137
|
+
.home-output-actions { width: 100%; justify-content: flex-start; }
|
|
138
|
+
.home-status { text-align: left; min-width: 0; }
|
|
139
|
+
.home-live > div { grid-template-columns: minmax(0, 1fr); gap: 2px; }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.home-pair { --qr-ink: #000; --qr-bg: #fff; display: flex; gap: 14px; align-items: flex-start; flex-wrap: wrap; }
|
|
143
|
+
.home-pair canvas, .home-pair img, .home-pair svg { background: #fff; padding: 8px; border-radius: 6px; flex-shrink: 0; }
|
|
144
|
+
.home-pair p, .home-tablet > p { font-size: 13px; margin: 0 0 8px; }
|
|
145
|
+
|
|
146
|
+
/* A view that is open now, next to its title; the section head lists who is connected. */
|
|
147
|
+
.home-open { margin-left: 8px; padding: 1px 7px; border-radius: 999px; font-size: 11px; font-weight: 500; color: var(--home-ok); background: rgba(129, 201, 149, 0.12); white-space: nowrap; }
|
|
148
|
+
.home-section-head .connected-views { color: var(--home-muted); }
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { h } from 'preact'
|
|
2
|
+
import { useEffect, useRef } from 'preact/hooks'
|
|
3
|
+
import { render } from 'preact'
|
|
4
|
+
import { marked, Parser } from 'marked'
|
|
5
|
+
import { registry } from '../runtime/registry'
|
|
6
|
+
import { InkLayer } from '../runtime/ink/InkLayer.jsx'
|
|
7
|
+
import themedSvgs from 'virtual:deck-svgs'
|
|
8
|
+
|
|
9
|
+
// ─── Content extraction ────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
const FN_DEF_RE = /^\[\^([^\]\n]+)\]:\s+([^\n]+)/gm
|
|
12
|
+
const FN_REF_RE = /\[\^([^\]\n]+)\]/g
|
|
13
|
+
|
|
14
|
+
function preprocessFootnotes(markdown) {
|
|
15
|
+
const defs = {}
|
|
16
|
+
const stripped = markdown.replace(FN_DEF_RE, (_, label, text) => {
|
|
17
|
+
defs[label] = text.trim()
|
|
18
|
+
return ''
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
let counter = 0
|
|
22
|
+
const labelToNum = {}
|
|
23
|
+
const processed = stripped.replace(FN_REF_RE, (_, label) => {
|
|
24
|
+
if (labelToNum[label] === undefined) labelToNum[label] = ++counter
|
|
25
|
+
return `<sup>${labelToNum[label]}</sup>`
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
if (counter === 0) return { processed: markdown, footnotesHtml: '' }
|
|
29
|
+
|
|
30
|
+
const items = Object.keys(labelToNum)
|
|
31
|
+
.map(label => `<li>${marked.parseInline(defs[label] ?? label)}</li>`)
|
|
32
|
+
.join('')
|
|
33
|
+
|
|
34
|
+
return { processed, footnotesHtml: `<ol>${items}</ol>` }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The deck's words for the audience (`slide.chapter` …), for layouts that show any.
|
|
38
|
+
export { t } from '../core/labels.js'
|
|
39
|
+
|
|
40
|
+
export function extractContent(markdown, { headingLevels = [], paragraph = false } = {}) {
|
|
41
|
+
const processed = markdown
|
|
42
|
+
const footnotesHtml = ''
|
|
43
|
+
const tokens = marked.lexer(processed)
|
|
44
|
+
const headings = {}
|
|
45
|
+
const paragraphs = []
|
|
46
|
+
const remaining = []
|
|
47
|
+
|
|
48
|
+
for (const token of tokens) {
|
|
49
|
+
if (token.type === 'heading' && headingLevels.includes(token.depth) && headings[token.depth] == null) {
|
|
50
|
+
headings[token.depth] = Parser.parseInline(token.tokens)
|
|
51
|
+
} else if (token.type === 'paragraph' && paragraph && paragraphs.length === 0 && !headings[2]) {
|
|
52
|
+
paragraphs.push(Parser.parseInline(token.tokens))
|
|
53
|
+
} else remaining.push(token)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const fullHtml = marked.parse(processed)
|
|
57
|
+
const bodyHtml = marked.parser(Object.assign(remaining, { links: tokens.links }))
|
|
58
|
+
return { headings, paragraphs, bodyHtml, fullHtml, footnotesHtml }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── Pictures ─────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
// An SVG picture that uses the theme's colour variables is drawn inline, so it
|
|
64
|
+
// follows the theme, palette and light or dark. The build lists them by the
|
|
65
|
+
// path the deck uses.
|
|
66
|
+
export const themedSvg = src => themedSvgs[src] ?? themedSvgs[String(src ?? '').split(/[?#]/)[0]]
|
|
67
|
+
|
|
68
|
+
const attribute = (tag, name) => tag.match(new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, 'i'))?.slice(1).find(v => v != null)
|
|
69
|
+
|
|
70
|
+
// Pictures in slide text keep the drawing's own size, like an <img>.
|
|
71
|
+
export function inlineThemedPictures(html) {
|
|
72
|
+
if (!html || !html.includes('<img')) return html
|
|
73
|
+
return html.replace(/<img\b[^>]*>/gi, tag => {
|
|
74
|
+
const markup = themedSvg(attribute(tag, 'src'))
|
|
75
|
+
if (!markup) return tag
|
|
76
|
+
const alt = attribute(tag, 'alt') ?? ''
|
|
77
|
+
return `<span class="themed-picture themed-picture--inline" role="img" aria-label="${alt}">${markup}</span>`
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A picture from the slide's settings; `fit: cover` fills its box like a
|
|
82
|
+
// photo, otherwise the drawing fits inside.
|
|
83
|
+
export function Picture({ src, alt, fit, position, class: className, ...attributes }) {
|
|
84
|
+
const markup = themedSvg(src)
|
|
85
|
+
if (!markup) return <img class={className} src={src} alt={alt} style={{ objectFit: fit, objectPosition: position }} {...attributes} />
|
|
86
|
+
const aspect = fit === 'cover' ? 'xMidYMid slice' : 'xMidYMid meet'
|
|
87
|
+
// The box sets the size; the drawing scales inside it.
|
|
88
|
+
const html = markup.replace(/<svg\b[^>]*>/, tag => tag.replace(/\s(width|height|preserveAspectRatio)="[^"]*"/g, '').replace('<svg', `<svg preserveAspectRatio="${aspect}"`))
|
|
89
|
+
return <div class={['themed-picture', className].filter(Boolean).join(' ')} role="img" aria-label={alt} dangerouslySetInnerHTML={{ __html: html }} {...attributes} />
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ─── Hydration for inline components ──────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
export function HtmlContent({ html, class: className, ...attributes }) {
|
|
95
|
+
const ref = useRef()
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (!ref.current) return
|
|
99
|
+
const mounted = []
|
|
100
|
+
for (const [name, Component] of Object.entries(registry)) {
|
|
101
|
+
for (const el of [...ref.current.querySelectorAll(name)]) {
|
|
102
|
+
const props = Object.fromEntries([...el.attributes].map(a => [a.name, a.value]))
|
|
103
|
+
const children = el.textContent
|
|
104
|
+
const wrapper = document.createElement('div')
|
|
105
|
+
el.replaceWith(wrapper)
|
|
106
|
+
render(h(Component, { ...props, children }), wrapper)
|
|
107
|
+
mounted.push(wrapper)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return () => mounted.forEach(wrapper => render(null, wrapper))
|
|
111
|
+
}, [html])
|
|
112
|
+
|
|
113
|
+
return <div class={className} {...attributes} ref={ref} dangerouslySetInnerHTML={{ __html: inlineThemedPictures(html) }} />
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─── Header / footer / footnotes rails ────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
function SlideHeader({ deckConfig, right, logo, isTitle = false }) {
|
|
119
|
+
const org = deckConfig.meta?.organization ?? ''
|
|
120
|
+
|
|
121
|
+
// show.organization: 'title' (default) | 'all' | 'none'
|
|
122
|
+
const instSetting = deckConfig.show?.organization ?? 'title'
|
|
123
|
+
const showOrg = instSetting === 'all' || (instSetting === 'title' && isTitle)
|
|
124
|
+
|
|
125
|
+
// show.sections: 'all' (default) | 'none'
|
|
126
|
+
const showSection = (deckConfig.show?.sections ?? 'all') !== 'none'
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div class="slide-header">
|
|
130
|
+
<span class="brand">{showOrg ? org : ''}</span>
|
|
131
|
+
{logo && <img class="slide-logo" src={logo} alt="" />}
|
|
132
|
+
{showSection && right != null && <span>{right}</span>}
|
|
133
|
+
</div>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function SlideFooter({ deckConfig, left, right, isTitle = false }) {
|
|
138
|
+
const { author, date } = deckConfig.meta ?? {}
|
|
139
|
+
const authorStr = [author, date].filter(Boolean).join(' · ')
|
|
140
|
+
|
|
141
|
+
// show.author (the author and date line): 'title' (default) | 'all' | 'none'
|
|
142
|
+
const adSetting = deckConfig.show?.author ?? 'title'
|
|
143
|
+
const showAuthor = adSetting === 'all' || (adSetting === 'title' && isTitle)
|
|
144
|
+
|
|
145
|
+
// show.numbers: 'slides' (default = all-except-title) | 'all' | 'none'
|
|
146
|
+
const pnSetting = deckConfig.show?.numbers ?? 'slides'
|
|
147
|
+
const showRight = pnSetting === 'all' || (pnSetting === 'slides' && !isTitle)
|
|
148
|
+
|
|
149
|
+
const leftContent = left ?? (showAuthor ? authorStr : '')
|
|
150
|
+
const footerClass = leftContent ? 'slide-footer' : 'slide-footer slide-footer--no-meta'
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<div class={footerClass}>
|
|
154
|
+
<span>{leftContent}</span>
|
|
155
|
+
{right != null && showRight && <span>{right}</span>}
|
|
156
|
+
</div>
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function SlideFootnotes({ html }) {
|
|
161
|
+
if (!html) return null
|
|
162
|
+
return <div class="slide-footnotes" dangerouslySetInnerHTML={{ __html: html }} />
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function slideNum(index) {
|
|
166
|
+
return String(index + 1).padStart(2, '0')
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
export function prepareSlide(slide) {
|
|
171
|
+
const position = region => region.source?.start ?? region.ranges?.[0]?.start ?? 0
|
|
172
|
+
const entries = Object.entries(slide.regions ?? { body: { name: 'body', content: slide.content } })
|
|
173
|
+
.sort((a, b) => position(a[1]) - position(b[1]))
|
|
174
|
+
let separator = '\u0000mdeck-region\u0000'
|
|
175
|
+
while (entries.some(([, r]) => r.content.includes(separator))) separator += '\u0000'
|
|
176
|
+
const { processed, footnotesHtml } = preprocessFootnotes(entries.map(([, r]) => r.content).join(separator))
|
|
177
|
+
const pieces = processed.split(separator)
|
|
178
|
+
const regions = Object.fromEntries(entries.map(([name, region], i) => [name, { ...region, content: pieces[i] }]))
|
|
179
|
+
return { ...slide, regions, content: regions.body?.content ?? '', footnotesHtml }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function MarkdownRegion({ region, content, class: className, ...attributes }) {
|
|
183
|
+
const name = typeof region === 'object' ? region.name : undefined
|
|
184
|
+
const markdown = content ?? (typeof region === 'string' ? region : region?.content) ?? ''
|
|
185
|
+
return <HtmlContent class={className} data-region={name} {...attributes} html={marked.parse(markdown)} />
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Layouts supply body content; every layout receives the same outer frame.
|
|
189
|
+
export function SlideFrame({ meta = {}, props = {}, deckConfig = {}, index = 0, id, manifest, footnotesHtml, children }) {
|
|
190
|
+
const layout = manifest.id === 'generic' ? meta.layout : manifest.id
|
|
191
|
+
const frame = manifest.frame ?? 'standard'
|
|
192
|
+
const title = frame === 'title'
|
|
193
|
+
const image = props.image
|
|
194
|
+
const classes = ['slide', layout && 'slide--' + String(layout).replace(/[^a-zA-Z0-9_-]/g, '-'),
|
|
195
|
+
image && ['title', 'chapter'].includes(layout) && 'has-image',
|
|
196
|
+
layout === 'full-bleed-image' && props.overlay && 'has-overlay'].filter(Boolean).join(' ')
|
|
197
|
+
return <section class={classes} data-slide-id={id} data-label={`${slideNum(index)} ${manifest.title}`}>
|
|
198
|
+
{frame !== 'none' && <SlideHeader deckConfig={deckConfig} logo={title ? deckConfig.meta?.logo : undefined}
|
|
199
|
+
isTitle={title} right={frame === 'chapter' ? meta.part ?? '' : meta.section ?? ''} />}
|
|
200
|
+
{children}
|
|
201
|
+
<SlideFootnotes html={footnotesHtml} />
|
|
202
|
+
{frame !== 'none' && <SlideFooter deckConfig={deckConfig} right={slideNum(index)} isTitle={title} />}
|
|
203
|
+
<InkLayer slideId={id} />
|
|
204
|
+
</section>
|
|
205
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Typed layout properties: shared by CLI validation, discovery and rendering.
|
|
2
|
+
// Built-in layout manifests live in assets/extensions/layouts/*/extension.toml.
|
|
3
|
+
|
|
4
|
+
export function resolveLayoutProps(manifest, meta = {}) {
|
|
5
|
+
const result = {}
|
|
6
|
+
for (const [key, schema] of Object.entries(manifest.properties ?? {})) {
|
|
7
|
+
if (schema.default !== undefined) result[key] = structuredClone(schema.default)
|
|
8
|
+
// Preserve legacy top-level fields for built-in layouts.
|
|
9
|
+
if (meta[key] !== undefined) result[key] = meta[key]
|
|
10
|
+
}
|
|
11
|
+
return { ...result, ...(meta.props ?? {}) }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function propertyErrors(value, schema, path) {
|
|
15
|
+
const errors = []
|
|
16
|
+
const valid = schema.type === 'array' ? Array.isArray(value)
|
|
17
|
+
: schema.type === 'object' ? value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
18
|
+
: schema.type === 'integer' ? Number.isInteger(value)
|
|
19
|
+
: typeof value === schema.type && (schema.type !== 'number' || Number.isFinite(value))
|
|
20
|
+
if (!valid) return [`${path} must be ${schema.type}`]
|
|
21
|
+
if (schema.enum && !schema.enum.includes(value)) errors.push(`${path} must be one of: ${schema.enum.join(', ')}`)
|
|
22
|
+
if (typeof value === 'number' && schema.minimum != null && value < schema.minimum) errors.push(`${path} must be at least ${schema.minimum}`)
|
|
23
|
+
if (typeof value === 'number' && schema.maximum != null && value > schema.maximum) errors.push(`${path} must be at most ${schema.maximum}`)
|
|
24
|
+
if (Array.isArray(value)) {
|
|
25
|
+
if (schema.minItems != null && value.length < schema.minItems) errors.push(`${path} needs at least ${schema.minItems} items`)
|
|
26
|
+
if (schema.maxItems != null && value.length > schema.maxItems) errors.push(`${path} allows at most ${schema.maxItems} items`)
|
|
27
|
+
if (schema.items) value.forEach((item, index) => errors.push(...propertyErrors(item, schema.items, `${path}[${index}]`)))
|
|
28
|
+
}
|
|
29
|
+
return errors
|
|
30
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { h } from 'preact'
|
|
2
|
+
import { layouts } from 'virtual:mdeck-extensions'
|
|
3
|
+
import { resolveLayoutProps } from './layoutProps'
|
|
4
|
+
import { SlideFrame, prepareSlide } from './layoutApi'
|
|
5
|
+
|
|
6
|
+
// Every layout — built-in or deck-local — arrives through the same registry.
|
|
7
|
+
export { layouts }
|
|
8
|
+
export const manifests = Object.fromEntries(Object.entries(layouts).map(([id, t]) => [id, t.manifest]))
|
|
9
|
+
|
|
10
|
+
export function SlideRenderer(slide) {
|
|
11
|
+
const id = slide.meta.layout ?? 'generic'
|
|
12
|
+
const entry = Object.hasOwn(layouts, id) ? layouts[id] : layouts.generic
|
|
13
|
+
const prepared = prepareSlide(slide)
|
|
14
|
+
const props = resolveLayoutProps(entry.manifest, slide.meta)
|
|
15
|
+
const context = { ...prepared, props, manifest: entry.manifest }
|
|
16
|
+
return <SlideFrame {...context}>{h(entry.render, context)}</SlideFrame>
|
|
17
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/* The phones' answer page. The presenter's screen sends the deck's colours
|
|
2
|
+
and fonts as CSS variables; these are only fallbacks. */
|
|
3
|
+
:root {
|
|
4
|
+
--bg: #fff; --ink: #111; --muted: #666; --rule: #d6d6d6; --accent: #0d9488;
|
|
5
|
+
--font-body: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
6
|
+
}
|
|
7
|
+
* { box-sizing: border-box; }
|
|
8
|
+
html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--ink); }
|
|
9
|
+
body { min-height: 100vh; display: flex; flex-direction: column; font: 18px/1.45 var(--font-body); -webkit-font-smoothing: antialiased; }
|
|
10
|
+
#answer { flex: 1; display: flex; flex-direction: column; }
|
|
11
|
+
.answer { flex: 1; width: 100%; max-width: 520px; margin: 0 auto; padding: 32px 20px; }
|
|
12
|
+
.answer h1 {
|
|
13
|
+
margin: 0 0 22px; font-size: clamp(24px, 7vw, 34px); line-height: 1.15;
|
|
14
|
+
font-family: var(--answer-heading-family, var(--font-display, var(--font-body)));
|
|
15
|
+
font-weight: var(--answer-heading-weight, 700);
|
|
16
|
+
letter-spacing: var(--answer-heading-spacing, normal);
|
|
17
|
+
text-transform: var(--answer-heading-transform, none);
|
|
18
|
+
}
|
|
19
|
+
.answer--waiting p { font-family: var(--answer-heading-family, var(--font-display, var(--font-body))); font-size: 22px; font-weight: 600; }
|
|
20
|
+
.answer-options, .answer-scale { display: grid; gap: 12px; }
|
|
21
|
+
.answer-scale { grid-template-columns: repeat(auto-fit, minmax(48px, 1fr)); }
|
|
22
|
+
.answer button {
|
|
23
|
+
padding: 18px 20px; border: 2px solid var(--rule); border-radius: 14px; background: var(--bg); color: var(--ink);
|
|
24
|
+
font: inherit; font-size: 20px; font-weight: 600; text-align: left; cursor: pointer; -webkit-tap-highlight-color: transparent;
|
|
25
|
+
}
|
|
26
|
+
.answer-scale button { padding: 16px 0; text-align: center; }
|
|
27
|
+
.answer button:hover { border-color: color-mix(in srgb, var(--accent) 60%, var(--rule)); }
|
|
28
|
+
.answer button.is-picked { border-color: var(--accent); background: var(--accent); color: var(--on-accent, var(--bg)); }
|
|
29
|
+
.answer-scale-labels { display: flex; justify-content: space-between; margin-top: 8px; color: var(--muted); font-size: 15px; }
|
|
30
|
+
.answer-text { display: grid; gap: 12px; }
|
|
31
|
+
.answer-text textarea { width: 100%; padding: 14px; border: 2px solid var(--rule); border-radius: 14px; background: var(--bg); color: var(--ink); font: inherit; resize: vertical; }
|
|
32
|
+
.answer-text button[type=submit] { justify-self: start; background: var(--accent); border-color: var(--accent); color: var(--on-accent, var(--bg)); }
|
|
33
|
+
.answer-status { margin: 18px 0 0; color: var(--muted); min-height: 2.8em; }
|
|
34
|
+
footer { padding: 16px 20px; text-align: center; color: var(--muted); font-size: 14px; }
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<meta name="robots" content="noindex" />
|
|
7
|
+
<title>Answer</title>
|
|
8
|
+
<link rel="stylesheet" href="answer.css" />
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<div id="answer"></div>
|
|
12
|
+
<script src="answer.js"></script>
|
|
13
|
+
</body>
|
|
14
|
+
</html>
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// The phone page the server serves at <server>/<code>. It needs no deck:
|
|
2
|
+
// the presenter's screen sends the activity on screen (what to ask and how to
|
|
3
|
+
// answer), the deck's look and the words in the deck's language, and this page
|
|
4
|
+
// shows them. Plain browser JavaScript, no build step.
|
|
5
|
+
(() => {
|
|
6
|
+
const base = new URL('.', location.href)
|
|
7
|
+
const code = location.pathname.split('/').filter(Boolean).pop()
|
|
8
|
+
const root = document.getElementById('answer')
|
|
9
|
+
const FALLBACK = {
|
|
10
|
+
en: { waiting: 'The next question will appear here.', pick: 'Tap one answer.', thanks: 'Thanks! You chose “{choice}”. Tap another to change.', send: 'Send', sent: 'Sent. Thank you!', offline: 'Connecting…' },
|
|
11
|
+
de: { waiting: 'Die nächste Frage erscheint hier.', pick: 'Eine Antwort antippen.', thanks: 'Danke! Gewählt: „{choice}“. Zum Ändern eine andere antippen.', send: 'Senden', sent: 'Gesendet. Danke!', offline: 'Verbinde…' },
|
|
12
|
+
}
|
|
13
|
+
let words = FALLBACK[navigator.language?.slice(0, 2) === 'de' ? 'de' : 'en']
|
|
14
|
+
let current = null
|
|
15
|
+
let connected = false
|
|
16
|
+
let status = ''
|
|
17
|
+
|
|
18
|
+
const me = (() => {
|
|
19
|
+
try {
|
|
20
|
+
let id = localStorage.getItem('mdeck-live-client')
|
|
21
|
+
// randomUUID only exists on https or localhost, not at `mdeck run --network`'s http://192.168…
|
|
22
|
+
if (!id) localStorage.setItem('mdeck-live-client', id = crypto.randomUUID?.() ?? Array.from(crypto.getRandomValues(new Uint8Array(16)), b => b.toString(16).padStart(2, '0')).join(''))
|
|
23
|
+
return id
|
|
24
|
+
} catch { return String(Math.random()).slice(2) }
|
|
25
|
+
})()
|
|
26
|
+
const chosenKey = room => `mdeck-answer:${code}.${room}`
|
|
27
|
+
const remembered = room => { try { return localStorage.getItem(chosenKey(room)) } catch { return null } }
|
|
28
|
+
const remember = (room, value) => { try { localStorage.setItem(chosenKey(room), value) } catch {} }
|
|
29
|
+
const fill = (text, vars) => text.replace(/\{(\w+)\}/g, (all, name) => vars[name] ?? all)
|
|
30
|
+
const el = (tag, props = {}, ...children) => {
|
|
31
|
+
const node = document.createElement(tag)
|
|
32
|
+
for (const [key, value] of Object.entries(props)) {
|
|
33
|
+
if (key.startsWith('on')) node.addEventListener(key.slice(2), value)
|
|
34
|
+
else if (value != null && value !== false) node.setAttribute(key, value === true ? '' : value)
|
|
35
|
+
}
|
|
36
|
+
node.append(...children.flat().filter(child => child != null))
|
|
37
|
+
return node
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The deck's colours and fonts, as the presenter's screen shows them.
|
|
41
|
+
function applyLook(look, lang) {
|
|
42
|
+
if (lang) document.documentElement.lang = lang
|
|
43
|
+
// No look: keep the one the page has, fonts included.
|
|
44
|
+
if (!look?.tokens) return
|
|
45
|
+
const style = document.documentElement.style
|
|
46
|
+
for (const [name, value] of Object.entries(look?.tokens ?? {})) if (/^--[\w-]+$/.test(name)) style.setProperty(name, value)
|
|
47
|
+
for (const [name, value] of Object.entries(look?.heading ?? {})) style.setProperty(`--answer-heading-${name}`, value)
|
|
48
|
+
const wanted = (look?.fonts ?? []).filter(url => /^https:\/\//.test(url))
|
|
49
|
+
for (const link of [...document.querySelectorAll('link[data-theme-font]')]) if (!wanted.includes(link.href)) link.remove()
|
|
50
|
+
for (const url of wanted) if (!document.querySelector(`link[data-theme-font][href="${CSS.escape(url)}"]`)) document.head.append(el('link', { rel: 'stylesheet', href: url, 'data-theme-font': true }))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function answer(room, value) {
|
|
54
|
+
const response = await fetch(new URL(`rooms/${encodeURIComponent(`${code}.${room}`)}`, base), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ from: me, data: { value } }) })
|
|
55
|
+
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error ?? String(response.status))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const FORMS = {
|
|
59
|
+
// { question, options: [..] }: one of several answers, which can be changed.
|
|
60
|
+
choice(activity, room) {
|
|
61
|
+
const picked = remembered(room)
|
|
62
|
+
return [
|
|
63
|
+
el('div', { class: 'answer-options' }, activity.options.map(option => el('button', {
|
|
64
|
+
class: option === picked ? 'is-picked' : null, 'aria-pressed': String(option === picked),
|
|
65
|
+
onclick: async () => {
|
|
66
|
+
try { await answer(room, option); remember(room, option); status = fill(words.thanks, { choice: option }) } catch (error) { status = error.message }
|
|
67
|
+
render()
|
|
68
|
+
},
|
|
69
|
+
}, option))),
|
|
70
|
+
el('p', { class: 'answer-status', role: 'status' }, status || (picked ? fill(words.thanks, { choice: picked }) : words.pick)),
|
|
71
|
+
]
|
|
72
|
+
},
|
|
73
|
+
// { question, placeholder, maxLength }: short text, as often as people like.
|
|
74
|
+
text(activity, room) {
|
|
75
|
+
const input = el('textarea', { rows: 3, maxlength: activity.maxLength ?? 200, placeholder: activity.placeholder ?? '' })
|
|
76
|
+
const form = el('form', { class: 'answer-text', onsubmit: async event => {
|
|
77
|
+
event.preventDefault()
|
|
78
|
+
const value = input.value.trim()
|
|
79
|
+
if (!value) return
|
|
80
|
+
try { await answer(room, value); status = words.sent } catch (error) { status = error.message }
|
|
81
|
+
render()
|
|
82
|
+
} }, input, el('button', { type: 'submit' }, words.send))
|
|
83
|
+
return [form, el('p', { class: 'answer-status', role: 'status' }, status)]
|
|
84
|
+
},
|
|
85
|
+
// { question, min, max, minLabel, maxLabel }: a number on a scale.
|
|
86
|
+
scale(activity, room) {
|
|
87
|
+
const picked = remembered(room)
|
|
88
|
+
const steps = []
|
|
89
|
+
for (let n = activity.min ?? 1; n <= (activity.max ?? 5); n++) steps.push(String(n))
|
|
90
|
+
return [
|
|
91
|
+
el('div', { class: 'answer-scale' }, steps.map(step => el('button', {
|
|
92
|
+
class: step === picked ? 'is-picked' : null, 'aria-pressed': String(step === picked),
|
|
93
|
+
onclick: async () => {
|
|
94
|
+
try { await answer(room, Number(step)); remember(room, step); status = fill(words.thanks, { choice: step }) } catch (error) { status = error.message }
|
|
95
|
+
render()
|
|
96
|
+
},
|
|
97
|
+
}, step))),
|
|
98
|
+
(activity.minLabel || activity.maxLabel) && el('div', { class: 'answer-scale-labels' }, el('span', {}, activity.minLabel ?? ''), el('span', {}, activity.maxLabel ?? '')),
|
|
99
|
+
el('p', { class: 'answer-status', role: 'status' }, status || (picked ? fill(words.thanks, { choice: picked }) : words.pick)),
|
|
100
|
+
]
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function render() {
|
|
105
|
+
const activity = current?.activity
|
|
106
|
+
const form = activity && FORMS[activity.type]
|
|
107
|
+
root.replaceChildren(
|
|
108
|
+
form
|
|
109
|
+
? el('main', { class: 'answer' }, activity.question && el('h1', {}, activity.question), form(activity, current.room))
|
|
110
|
+
: el('main', { class: 'answer answer--waiting' }, el('p', {}, connected ? words.waiting : words.offline)),
|
|
111
|
+
current?.title ? el('footer', {}, current.title) : null,
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function follow(state) {
|
|
116
|
+
const previous = current?.room
|
|
117
|
+
current = state
|
|
118
|
+
if (state?.labels) words = { ...words, ...state.labels }
|
|
119
|
+
applyLook(state?.look, state?.lang)
|
|
120
|
+
if (state?.room !== previous) status = ''
|
|
121
|
+
render()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
render()
|
|
125
|
+
// view=phone: the presenter sees how many phones are connected.
|
|
126
|
+
const source = new EventSource(new URL(`rooms/${encodeURIComponent(code)}/events?view=phone&device=phone`, base))
|
|
127
|
+
source.addEventListener('snapshot', event => { connected = true; follow(JSON.parse(event.data).state) })
|
|
128
|
+
source.addEventListener('error', () => { connected = false; render() })
|
|
129
|
+
source.addEventListener('state', event => follow(JSON.parse(event.data).state))
|
|
130
|
+
})()
|