reveal.js-phone-remote 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jan Huege
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,184 @@
1
+ # reveal.js-phone-remote
2
+
3
+ Control a [reveal.js](https://revealjs.com) deck from your phone – no app, no
4
+ cloud, no second server. Scan a QR code in the terminal and the phone shows:
5
+
6
+ - slide number, title, next slide and speaker notes (A−/A+ for size)
7
+ - timer (▶ to start, ↺ to reset; otherwise starts with the first navigation) and clock
8
+ - Next / Back, Up / Down for vertical slides, black screen (reveal's pause), jump to any slide
9
+ - swipe over the notes to change slides
10
+ - **Spotlight** – a trackpad like the Logitech Spotlight: touch and everything
11
+ but a circle dims, move and the circle follows, lift and it's gone. "Hold"
12
+ keeps it, S/M/L sets the size. Turns off by itself when the phone
13
+ disconnects.
14
+
15
+ It runs on your existing Vite dev/preview server (or any Connect/Express/
16
+ node:http server) via Server-Sent Events. Only dependency: `qrcode-terminal`.
17
+
18
+ ## Try it
19
+
20
+ The repository has a demo deck in two flavours:
21
+
22
+ ```sh
23
+ git clone https://github.com/JanHuege/reveal.js-phone-remote.git && cd reveal.js-phone-remote
24
+ npm install
25
+ npm run example # Vite – http://localhost:5173/
26
+ npm run example:node # plain node:http – http://localhost:8000/
27
+ ```
28
+
29
+ Open the deck on the laptop and scan the QR code in the terminal with your
30
+ phone. Details: [`examples/vite`](examples/vite/), [`examples/node`](examples/node/).
31
+
32
+ ## Install
33
+
34
+ ```sh
35
+ npm install reveal.js-phone-remote
36
+ ```
37
+
38
+ ## Usage with Vite
39
+
40
+ ```js
41
+ // vite.config.js
42
+ import { defineConfig } from 'vite';
43
+ import phoneRemote from 'reveal.js-phone-remote/vite';
44
+
45
+ export default defineConfig({
46
+ plugins: [phoneRemote()],
47
+ // Listen on all interfaces so the phone can reach the laptop.
48
+ // '::' = dual stack (IPv4 + IPv6), needed in IPv6-only phone hotspots.
49
+ server: { host: '::' },
50
+ preview: { host: '::' },
51
+ });
52
+ ```
53
+
54
+ ```js
55
+ // main.js
56
+ import Reveal from 'reveal.js';
57
+ import PhoneRemote from 'reveal.js-phone-remote';
58
+
59
+ Reveal.initialize({
60
+ plugins: [PhoneRemote],
61
+ });
62
+ ```
63
+
64
+ Start `vite` (or `vite build && vite preview`), open the deck on the laptop and
65
+ scan the QR code in the terminal with the phone camera. Both devices must be in
66
+ the same network – a phone hotspot is the safest bet in lecture halls, where
67
+ Wi-Fi often isolates clients.
68
+
69
+ Without the server (static hosting, PDF export, speaker view iframes) the deck
70
+ plugin stays silent.
71
+
72
+ ## Usage without Vite
73
+
74
+ See [`examples/node`](examples/node/) for a complete, bundler-free setup.
75
+
76
+ ```js
77
+ import http from 'node:http';
78
+ import { createRemote, printConnectInfo } from 'reveal.js-phone-remote/server';
79
+
80
+ const remote = createRemote();
81
+ const server = http.createServer((req, res) =>
82
+ remote.handle(req, res, () => serveYourDeck(req, res)),
83
+ );
84
+ server.listen(8000, '::');
85
+ printConnectInfo(server, remote);
86
+ ```
87
+
88
+ `remote.handle(req, res, next)` is a Connect-style middleware, so
89
+ `app.use(remote.handle)` works with Express and Connect.
90
+
91
+ ## Options
92
+
93
+ ### Server (`phoneRemote(options)` / `createRemote(options)`)
94
+
95
+ | Option | Default | |
96
+ | --- | --- | --- |
97
+ | `key` | `$REMOTE_KEY` or random | Access key, embedded in the QR code. Set a fixed one to keep a home-screen bookmark valid across restarts. |
98
+ | `prefix` | `'/_remote'` | URL prefix of all endpoints and the phone page. |
99
+ | `lang` | `'en'` | Phone UI language: `'en'`, `'de'`. |
100
+ | `strings` | `{}` | Override single UI strings, see `src/phone/i18n.js`. |
101
+ | `title` | `'Remote'` | Page title and home-screen name. |
102
+ | `themeColor` | `'#0b1020'` | Browser chrome colour on the phone. |
103
+ | `css` | – | Path to an extra stylesheet for the phone page. |
104
+ | `qr` | `true` | Vite only: print the QR code. |
105
+
106
+ ### Deck (`phoneRemote` key in the reveal config)
107
+
108
+ ```js
109
+ Reveal.initialize({
110
+ plugins: [PhoneRemote],
111
+ phoneRemote: {
112
+ prefix: '/_remote', // must match the server
113
+ slideLabel: 'Slide', // fallback title: "Slide 3"
114
+ title: (slide, index) => …, // custom title extraction, falsy = default
115
+ },
116
+ });
117
+ ```
118
+
119
+ Default slide title: first `h1`–`h3` of the slide.
120
+
121
+ ## Theming
122
+
123
+ The phone page uses CSS custom properties. Override them in a stylesheet
124
+ passed as `css`:
125
+
126
+ ```css
127
+ :root {
128
+ --bg: #07102b;
129
+ --panel: #0e1b42;
130
+ --accent: #418fde;
131
+ --highlight: #c0ba00;
132
+ --font: 'Inter', system-ui, sans-serif;
133
+ --radius: 0 14px 0 14px;
134
+ }
135
+ ```
136
+
137
+ All properties: see the top of `src/phone/remote.css`.
138
+
139
+ The spotlight's dim colour on the deck: `.reveal-spotlight { --spotlight-dim: rgb(0 0 0 / 0.8); }`.
140
+
141
+ ## Standalone spotlight
142
+
143
+ The spotlight overlay can be driven by something else too:
144
+
145
+ ```js
146
+ import { createSpotlight } from 'reveal.js-phone-remote/spotlight';
147
+
148
+ const spot = createSpotlight(Reveal);
149
+ spot.update({ on: true, x: 0.5, y: 0.3, r: 0.14 }); // normalised to the slide
150
+ ```
151
+
152
+ `x`, `y` in 0..1 of the slide, `r` as fraction of the slide height.
153
+
154
+ ## Security
155
+
156
+ Everyone in the same network reaches the server. Commands, state and notes
157
+ require the key, which is rolled at start and only travels in the QR code.
158
+ Plain HTTP – use it in networks you trust, or a phone hotspot.
159
+
160
+ ## Notes on iOS
161
+
162
+ - "Add to Home Screen" in Safari turns the page into a full-screen web app.
163
+ - Over `http://` in the LAN there is no Wake Lock API – set Auto-Lock to
164
+ "Never" for the talk.
165
+ - A gyroscope pointer like the original Logitech Spotlight isn't possible:
166
+ iOS only exposes motion sensors to HTTPS pages.
167
+ - In an iPhone hotspot, the QR code uses the Mac's Bonjour name (`….local`):
168
+ the iPhone would route the Mac's global IPv6 address over cellular instead of
169
+ the hotspot. Start the server *after* joining the network.
170
+
171
+ ## Protocol
172
+
173
+ | | |
174
+ | --- | --- |
175
+ | `GET <prefix>/` | phone page |
176
+ | `GET <prefix>/ping` | deck checks for the server |
177
+ | `GET <prefix>/events?role=deck` | commands to the deck (SSE) |
178
+ | `GET <prefix>/events?role=remote&key=…` | deck state to the phone (SSE) |
179
+ | `POST <prefix>/state` | deck reports its state |
180
+ | `POST <prefix>/command` (`X-Remote-Key`) | `next`, `prev`, `left`, `right`, `up`, `down`, `first`, `last`, `pause`, `goto {h, v}`, `spot {on, x, y, r}` |
181
+
182
+ ## License
183
+
184
+ [MIT](LICENSE)
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "reveal.js-phone-remote",
3
+ "version": "0.1.0",
4
+ "description": "Control a reveal.js deck from your phone: speaker notes, timer, slide jump and a Logitech-style spotlight. No app, no cloud.",
5
+ "type": "module",
6
+ "author": "Jan Huege",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/JanHuege/reveal.js-phone-remote.git"
11
+ },
12
+ "homepage": "https://github.com/JanHuege/reveal.js-phone-remote#readme",
13
+ "bugs": "https://github.com/JanHuege/reveal.js-phone-remote/issues",
14
+ "keywords": [
15
+ "reveal.js",
16
+ "reveal.js-plugin",
17
+ "remote",
18
+ "presenter",
19
+ "spotlight",
20
+ "speaker-notes",
21
+ "vite-plugin"
22
+ ],
23
+ "exports": {
24
+ ".": "./src/deck/plugin.js",
25
+ "./spotlight": "./src/deck/spotlight.js",
26
+ "./server": "./src/server/index.js",
27
+ "./vite": "./src/server/vite.js"
28
+ },
29
+ "files": [
30
+ "src",
31
+ "README.md"
32
+ ],
33
+ "engines": {
34
+ "node": ">=18"
35
+ },
36
+ "scripts": {
37
+ "example": "npm run dev -w examples/vite",
38
+ "example:preview": "npm run preview -w examples/vite",
39
+ "example:node": "npm start -w examples/node"
40
+ },
41
+ "workspaces": [
42
+ "examples/*"
43
+ ],
44
+ "dependencies": {
45
+ "qrcode-terminal": "^0.12.0"
46
+ },
47
+ "peerDependencies": {
48
+ "reveal.js": ">=4",
49
+ "vite": ">=5"
50
+ },
51
+ "peerDependenciesMeta": {
52
+ "vite": {
53
+ "optional": true
54
+ }
55
+ }
56
+ }
@@ -0,0 +1,189 @@
1
+ // reveal.js plugin – the deck side of the remote.
2
+ //
3
+ // Reports every slide change to the server and runs the remote's commands.
4
+ // Stays silent when the server isn't there (static hosting, PDF export,
5
+ // speaker view).
6
+ //
7
+ // import PhoneRemote from 'reveal.js-phone-remote';
8
+ // Reveal.initialize({ plugins: [PhoneRemote], phoneRemote: { … } });
9
+
10
+ import { createSpotlight } from './spotlight.js';
11
+
12
+ const DEFAULTS = {
13
+ // Must match the server's prefix.
14
+ prefix: '/_remote',
15
+ // Fallback title for slides without h1–h3: "<slideLabel> <n>".
16
+ slideLabel: 'Slide',
17
+ // Custom title extraction: (slide, index) => string | null.
18
+ title: null,
19
+ };
20
+
21
+ const REPORT_ON = [
22
+ 'slidechanged',
23
+ 'fragmentshown',
24
+ 'fragmenthidden',
25
+ 'paused',
26
+ 'resumed',
27
+ 'overviewshown',
28
+ 'overviewhidden',
29
+ ];
30
+
31
+ export default function PhoneRemote() {
32
+ return {
33
+ id: 'phone-remote',
34
+ init(deck) {
35
+ const options = { ...DEFAULTS, ...deck.getConfig().phoneRemote };
36
+ // Not awaited: an unreachable server must not delay reveal's start.
37
+ if (deck.isReady()) connect(deck, options);
38
+ else deck.on('ready', () => connect(deck, options));
39
+ },
40
+ };
41
+ }
42
+
43
+ async function connect(deck, options) {
44
+ const { prefix } = options;
45
+ const params = new URLSearchParams(location.search);
46
+ // The speaker view loads the deck in iframes with ?receiver. Those must
47
+ // neither report nor run commands – every command would run twice.
48
+ if (params.has('print-pdf') || params.has('receiver')) return;
49
+ if (window.self !== window.top) return;
50
+
51
+ try {
52
+ const ping = await fetch(`${prefix}/ping`);
53
+ if (!ping.ok) return;
54
+ } catch {
55
+ return;
56
+ }
57
+
58
+ // At most one report in flight, the snapshot is taken when it leaves.
59
+ // Parallel POSTs can arrive out of order – an older state would then
60
+ // overwrite a newer one on the phone.
61
+ let inFlight = false;
62
+ let queued = false;
63
+ const report = async () => {
64
+ queued = true;
65
+ if (inFlight) return;
66
+ inFlight = true;
67
+ while (queued) {
68
+ queued = false;
69
+ await fetch(`${prefix}/state`, {
70
+ method: 'POST',
71
+ headers: { 'Content-Type': 'application/json' },
72
+ body: JSON.stringify(snapshot(deck, options)),
73
+ }).catch(() => {});
74
+ }
75
+ inFlight = false;
76
+ };
77
+
78
+ REPORT_ON.forEach((type) => deck.on(type, report));
79
+
80
+ const spotlight = createSpotlight(deck);
81
+
82
+ const events = new EventSource(`${prefix}/events?role=deck`);
83
+ // Report after every (re)connect – the server may have restarted.
84
+ events.addEventListener('open', report);
85
+ events.addEventListener('command', (e) => {
86
+ const command = JSON.parse(e.data);
87
+ if (command.cmd === 'spot') spotlight.update(command);
88
+ else run(deck, command);
89
+ });
90
+ }
91
+
92
+ function run(deck, { cmd, h, v }) {
93
+ if (deck.isOverview() && cmd !== 'pause') deck.toggleOverview(false);
94
+
95
+ switch (cmd) {
96
+ case 'next':
97
+ deck.next();
98
+ break;
99
+ case 'prev':
100
+ deck.prev();
101
+ break;
102
+ // Left/right: fragments first, then the neighbouring main slide –
103
+ // subslides are skipped (that's what up/down are for).
104
+ case 'right':
105
+ deck.right();
106
+ break;
107
+ case 'left':
108
+ deck.left();
109
+ break;
110
+ case 'up':
111
+ deck.up();
112
+ break;
113
+ case 'down':
114
+ deck.down();
115
+ break;
116
+ case 'first':
117
+ deck.slide(0, 0);
118
+ break;
119
+ case 'last':
120
+ deck.slide(deck.getHorizontalSlides().length - 1, 0);
121
+ break;
122
+ case 'pause':
123
+ deck.togglePause();
124
+ break;
125
+ case 'goto':
126
+ deck.slide(Number(h) || 0, Number(v) || 0);
127
+ break;
128
+ }
129
+ }
130
+
131
+ function snapshot(deck, options) {
132
+ const slides = deck.getSlides();
133
+ const current = deck.getCurrentSlide();
134
+ const index = slides.indexOf(current);
135
+ const { h, v, f } = deck.getIndices();
136
+ const titleOf = (slide, i) => options.title?.(slide, i) || defaultTitle(slide, i, options);
137
+
138
+ const main = deck.getHorizontalSlides();
139
+ const nextMain = main[h + 1];
140
+ const nextSlide = nextMain && (nextMain.querySelector(':scope > section') || nextMain);
141
+
142
+ const fragments = current.querySelectorAll('.fragment');
143
+ const shown = current.querySelectorAll('.fragment.visible');
144
+
145
+ return {
146
+ h,
147
+ v,
148
+ f,
149
+ index,
150
+ total: slides.length,
151
+ title: titleOf(current, index),
152
+ notes: deck.getSlideNotes(current) || '',
153
+ fragments: fragments.length,
154
+ fragmentsShown: shown.length,
155
+ // "Next" on the phone goes right, so "up next" is the next main slide.
156
+ next: nextSlide ? titleOf(nextSlide, slides.indexOf(nextSlide)) : null,
157
+ paused: deck.isPaused(),
158
+ // Which ways lead somewhere – for the up/down buttons.
159
+ routes: routesOf(current, v),
160
+ aspect: aspectOf(deck),
161
+ // Outline for jumping to a slide.
162
+ outline: slides.map((slide, i) => ({
163
+ ...deck.getIndices(slide),
164
+ title: titleOf(slide, i),
165
+ })),
166
+ };
167
+ }
168
+
169
+ // Up/down from the current slide's own stack, so they always match h/v of
170
+ // the same snapshot. deck.availableRoutes() reads reveal's .present classes
171
+ // and turns up/down on for every slide when loop is set.
172
+ function routesOf(slide, v = 0) {
173
+ const stack = slide.parentElement?.closest('section');
174
+ const count = stack ? stack.querySelectorAll(':scope > section').length : 1;
175
+ return { up: v > 0, down: v < count - 1 };
176
+ }
177
+
178
+ // Slide width / height, so the phone's trackpad moves the spot evenly.
179
+ function aspectOf(deck) {
180
+ const { width, height } = deck.getConfig();
181
+ const ratio = Number(width) / Number(height);
182
+ return Number.isFinite(ratio) && ratio > 0 ? ratio : null;
183
+ }
184
+
185
+ function defaultTitle(slide, index, { slideLabel }) {
186
+ const heading = slide.querySelector('h1, h2, h3');
187
+ const text = heading?.textContent.replace(/\s+/g, ' ').trim();
188
+ return text || `${slideLabel} ${index + 1}`;
189
+ }
@@ -0,0 +1,100 @@
1
+ // Styles are injected, so the module works without a CSS-aware bundler.
2
+ // Override the dim colour with `--spotlight-dim` on `.reveal-spotlight`.
3
+
4
+ const FOLLOW = 0.35; // share of the remaining distance per frame
5
+ const STYLE_ID = "reveal.js-phone-remote-spotlight";
6
+ const CSS = `
7
+ .reveal-spotlight {
8
+ position: fixed;
9
+ inset: 0;
10
+ z-index: 100;
11
+ pointer-events: none;
12
+ background: radial-gradient(
13
+ circle at var(--x, 50%) var(--y, 50%),
14
+ transparent calc(var(--r, 150px) - 6px),
15
+ var(--spotlight-dim, rgb(0 0 0 / 0.8)) var(--r, 150px)
16
+ );
17
+ opacity: 0;
18
+ transition: opacity 0.18s ease-out;
19
+ }
20
+ .reveal-spotlight.on {
21
+ opacity: 1;
22
+ }
23
+ @media print {
24
+ .reveal-spotlight {
25
+ display: none;
26
+ }
27
+ }
28
+ `;
29
+
30
+ export function createSpotlight(deck) {
31
+ if (!document.getElementById(STYLE_ID)) {
32
+ const style = document.createElement("style");
33
+ style.id = STYLE_ID;
34
+ style.textContent = CSS;
35
+ document.head.appendChild(style);
36
+ }
37
+
38
+ const el = document.createElement("div");
39
+ el.className = "reveal-spotlight";
40
+ el.setAttribute("aria-hidden", "true");
41
+ document.body.appendChild(el);
42
+
43
+ const target = { x: 0.5, y: 0.5, r: 0.14 };
44
+ const current = { ...target };
45
+ let visible = false;
46
+ let frame = 0;
47
+
48
+ function paint() {
49
+ const box = deck.getSlidesElement().getBoundingClientRect();
50
+ const x = box.left + current.x * box.width;
51
+ const y = box.top + current.y * box.height;
52
+ const r = current.r * box.height;
53
+ el.style.setProperty("--x", `${x}px`);
54
+ el.style.setProperty("--y", `${y}px`);
55
+ el.style.setProperty("--r", `${r}px`);
56
+ }
57
+
58
+ function step() {
59
+ let moving = false;
60
+ for (const k of ["x", "y", "r"]) {
61
+ const d = target[k] - current[k];
62
+ if (Math.abs(d) > 0.0005) {
63
+ current[k] += d * FOLLOW;
64
+ moving = true;
65
+ } else {
66
+ current[k] = target[k];
67
+ }
68
+ }
69
+ paint();
70
+ frame = moving ? requestAnimationFrame(step) : 0;
71
+ }
72
+
73
+ function update({ on, x, y, r }) {
74
+ if (Number.isFinite(x)) target.x = clamp(x);
75
+ if (Number.isFinite(y)) target.y = clamp(y);
76
+ if (Number.isFinite(r)) target.r = Math.min(0.5, Math.max(0.03, r));
77
+
78
+ const show = on !== false;
79
+ if (show && !visible) {
80
+ // Don't fly across the slide when fading in.
81
+ Object.assign(current, target);
82
+ paint();
83
+ }
84
+ visible = show;
85
+ el.classList.toggle("on", visible);
86
+
87
+ if (!frame) frame = requestAnimationFrame(step);
88
+ }
89
+
90
+ function destroy() {
91
+ cancelAnimationFrame(frame);
92
+ window.removeEventListener("resize", paint);
93
+ el.remove();
94
+ }
95
+
96
+ window.addEventListener("resize", paint);
97
+ return { update, destroy };
98
+ }
99
+
100
+ const clamp = (v) => Math.min(1, Math.max(0, v));
@@ -0,0 +1,82 @@
1
+ export const STRINGS = {
2
+ en: {
3
+ connection: "Connection",
4
+ connecting: "Connecting …",
5
+ upNext: "Up next:",
6
+ end: "End",
7
+ noNotes: "No notes.",
8
+ step: "Step {shown} / {total}",
9
+ slides: "Slides",
10
+ spot: "Spot",
11
+ black: "Black",
12
+ show: "Show",
13
+ prev: "Back",
14
+ next: "Next",
15
+ up: "Up",
16
+ down: "Down",
17
+ notesSmaller: "Smaller notes",
18
+ notesLarger: "Larger notes",
19
+ timerToggle: "Start/stop timer",
20
+ timerReset: "Reset timer",
21
+ jumpTo: "Jump to",
22
+ done: "Done",
23
+ spotlight: "Spotlight",
24
+ spotHint: "Touch – the spot appears.\nMove – the spot follows.",
25
+ spotSize: "Spot size",
26
+ hold: "Hold",
27
+ noAccess: "No access – scan the QR code in the terminal again.",
28
+ reconnecting: "Connection lost – reconnecting …",
29
+ unreachable: "Server unreachable.",
30
+ noDeck: "Presentation not connected – open the deck in the browser.",
31
+ },
32
+ de: {
33
+ connection: "Verbindung",
34
+ connecting: "Verbinde …",
35
+ upNext: "Als Nächstes:",
36
+ end: "Ende",
37
+ noNotes: "Keine Notizen.",
38
+ step: "Schritt {shown} / {total}",
39
+ slides: "Folien",
40
+ spot: "Spot",
41
+ black: "Schwarz",
42
+ show: "Zeigen",
43
+ prev: "Zurück",
44
+ next: "Weiter",
45
+ up: "Hoch",
46
+ down: "Runter",
47
+ notesSmaller: "Notizen kleiner",
48
+ notesLarger: "Notizen größer",
49
+ timerToggle: "Timer starten/anhalten",
50
+ timerReset: "Timer zurücksetzen",
51
+ jumpTo: "Springen zu",
52
+ done: "Fertig",
53
+ spotlight: "Spotlight",
54
+ spotHint:
55
+ "Finger auflegen – der Spot erscheint.\nBewegen – der Spot folgt.",
56
+ spotSize: "Spotgröße",
57
+ hold: "Halten",
58
+ noAccess: "Kein Zugriff – QR-Code im Terminal neu scannen.",
59
+ reconnecting: "Verbindung unterbrochen – verbinde neu …",
60
+ unreachable: "Server nicht erreichbar.",
61
+ noDeck: "Präsentation nicht verbunden – Deck im Browser öffnen.",
62
+ },
63
+ };
64
+
65
+ export function translator(lang, overrides = {}) {
66
+ const base = STRINGS[lang] || STRINGS[lang?.split("-")[0]] || STRINGS.en;
67
+ const strings = { ...STRINGS.en, ...base, ...overrides };
68
+ return (id, vars = {}) =>
69
+ strings[id].replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? ""));
70
+ }
71
+
72
+ export function translate(root, t) {
73
+ root.querySelectorAll("[data-i18n]").forEach((el) => {
74
+ el.textContent = t(el.dataset.i18n);
75
+ });
76
+ root.querySelectorAll("[data-i18n-label]").forEach((el) => {
77
+ el.setAttribute("aria-label", t(el.dataset.i18nLabel));
78
+ });
79
+ root.querySelectorAll("[data-i18n-title]").forEach((el) => {
80
+ el.title = t(el.dataset.i18nTitle);
81
+ });
82
+ }