pindeck 3.0.0-beta.2 → 3.0.0-beta.3

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # PinDeck
2
2
 
3
- > **macOS, and Linux as a beta · Version 3 beta preview** (`3.0.0-beta.2`). On Linux, PinDeck runs as a
3
+ > **macOS, and Linux as a beta · Version 3 beta preview** (`3.0.0-beta.3`). On Linux, PinDeck runs as a
4
4
  > headless server that you use from your Mac's browser through an SSH tunnel (see
5
5
  > [Running on a Linux server](#running-on-a-linux-server-beta)). Windows is not supported. The beta is on
6
6
  > npm’s `beta` channel and the [`v3-beta`](https://github.com/ehabets/PinDeck/tree/v3-beta) branch; stable
@@ -42,7 +42,7 @@ Install the beta from npm:
42
42
  ```bash
43
43
  npx pindeck@beta /path/to/deck
44
44
  # Or select this exact beta:
45
- npx pindeck@3.0.0-beta.2 /path/to/deck
45
+ npx pindeck@3.0.0-beta.3 /path/to/deck
46
46
  ```
47
47
 
48
48
  Or run it from the `v3-beta` branch, which includes the example deck:
@@ -101,7 +101,8 @@ errors; PinDeck does not fall back to broader permissions. Recheck backend compa
101
101
 
102
102
  ## Preparing a deck
103
103
 
104
- PinDeck works with reveal.js decks whose slides are `<section>` elements inside `.slides`. Two
104
+ PinDeck works with reveal.js decks whose slides are `<section>` elements inside `.slides`, and with other
105
+ decks that name their slides (see [Decks made without reveal.js](#decks-made-without-revealjs)). Two
105
106
  optional `<meta>` tags in the deck's `<head>` tell it about the design:
106
107
 
107
108
  ```html
@@ -116,6 +117,40 @@ numbers will jump where a slide is hidden.
116
117
  Without them PinDeck runs no footer check and locks only `.slide-foot` and `.logo`. A file called
117
118
  `brand.css` in the deck folder is locked for both backends. See `ocean-example/` for a complete deck.
118
119
 
120
+ ### Decks made without reveal.js
121
+
122
+ A deck with a script of its own, or made with a framework whose slides are elements of their own
123
+ (Shower, deck.js, a hand-written deck), can be edited too once it names its slides in its `<head>`:
124
+
125
+ ```html
126
+ <meta name="pindeck:slides" content="main.deck > section.slide"> <!-- a CSS selector for the slides -->
127
+ <meta name="pindeck:size" content="1440x1080"> <!-- their size in px -->
128
+ ```
129
+
130
+ The selector may use tag names, `#id`, `.class`, `[attr]` and `[attr="value"]`, joined by spaces or `>`,
131
+ and a list of them separated by commas. In the editor, PinDeck shows the slides itself, one at a time and
132
+ scaled to the window, and switches off the deck's own scaling, slide keys and inert or hidden marks while
133
+ you edit; that changes nothing in the deck file. Everything in [Working with it](#working-with-it) works as
134
+ with a reveal.js deck, with these differences:
135
+
136
+ * Build steps (`.fragment`) belong to reveal.js: other decks show every element at once.
137
+ * Hidden slides (`data-visibility="hidden"`) are left out of PinDeck's PDF exports; whether presenting
138
+ skips them is up to the deck's own script.
139
+ * Speaker notes are an `<aside class="notes">` in the slide, as in reveal.js. PinDeck hides them in the
140
+ editor; the deck's CSS should hide them too (`aside.notes { display: none }`).
141
+ * **Present** opens the deck at `#<slide id>`, or `#<number>` (counting from 1) for a slide without an id.
142
+ Present may open on the first slide if the deck does not go to slides by their address.
143
+ * **+ New slide** models a new slide on the deck's own (see [Making your own template](#making-your-own-template)):
144
+ a content slide gets placeholder bullets in a `.slide-body`; without one it has only its title. A new or
145
+ duplicated slide does not take the original's `id`.
146
+
147
+ Both tags are needed: PinDeck opens the slides at the given size for screenshots and PDF pages. A missing
148
+ size, or a selector that finds no slides or that PinDeck cannot read, is reported before anything changes.
149
+ While PinDeck runs, it reads the tags again for every screenshot, PDF and assistant session; the editor
150
+ reads them when the slide reloads. If a tag stops working meanwhile, the editor shows the same message and
151
+ the assistant is told. A session already running keeps the slide selector it started with; **New chat**
152
+ starts one with the new selector.
153
+
119
154
  ## Making your own template
120
155
 
121
156
  PinDeck has no template generator; a template is simply a deck folder. Start from a copy of the example:
@@ -448,8 +483,10 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
448
483
 
449
484
  ## Current Limitations
450
485
 
451
- * reveal.js decks only. A deck made with another framework (Marp, Slidev, impress.js, remark, Spectacle,
452
- Shower, deck.js, bespoke.js) is recognised and refused with its name before PinDeck changes anything.
486
+ * reveal.js decks, and decks that name their slides with `<meta name="pindeck:slides">` (see [Decks made
487
+ without reveal.js](#decks-made-without-revealjs)). Any other deck made with another framework (Marp, Slidev,
488
+ impress.js, remark, Spectacle, Shower, deck.js, bespoke.js) is recognised and refused with its name before
489
+ PinDeck changes anything.
453
490
  * Text inside inline SVG diagrams cannot be edited in place; pin it and ask the assistant.
454
491
  * Parts of an inline SVG move with the whole SVG; inline text (bold, spans) moves with its block.
455
492
  * No indenting or outdenting of bullets with `Tab` yet, and `Backspace` at the start of a bullet does not merge it with the one above; ask the assistant for those.
@@ -461,6 +498,12 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
461
498
 
462
499
  ## Versions
463
500
 
501
+ * **3.0.0-beta.3**: everything from 2.10.0, for both assistants.
502
+ - Decks made without reveal.js: a deck that names its slides with `<meta name="pindeck:slides">`
503
+ and their size with `<meta name="pindeck:size">` is edited like a reveal.js deck (contributed by
504
+ Zeyu Xu). Claude and Codex are both told what the deck's slides are.
505
+ - Screenshots fill the picture instead of being letterboxed into a shorter page.
506
+
464
507
  * **3.0.0-beta.2**: everything from 2.9 and 2.10, with Codex brought up to it.
465
508
  - Security: the fix from 2.9. The editor page no longer contains the token, and the deck needs it
466
509
  too, so other users of the same machine cannot read either. PinDeck prints a one-time login link at
@@ -480,8 +523,8 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
480
523
  switching with optional handover, curated model lists, a separate effort selector with per-model
481
524
  preferences, restricted Codex tools, and separate v3 runtime state. On npm as `pindeck@beta`.
482
525
 
483
- * **2.10.0-beta.1** (2.10 beta): decks with their own file names and shared files, and Claude working
484
- on the whole deck.
526
+ * **2.10**: decks with their own file names and shared files, decks made without reveal.js, and Claude
527
+ working on the whole deck.
485
528
  - A deck file by name: `pindeck path/to/talk.html` (a folder still opens its `index.html`). Several
486
529
  decks can share a folder, each with its own chat and undo history.
487
530
  - Claude may edit and create other files of the deck (scripts, style sheets, notes), never
@@ -492,8 +535,14 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
492
535
  screenshots and PDF exports. A symlink that leads out of the deck and its asset roots is refused.
493
536
  - Shared instructions: the deck's `CLAUDE.md` and `AGENTS.md` may import other files with `@path`.
494
537
  `CLAUDE.md` files in parent folders are still not loaded, on purpose (see the README).
495
- - Decks made with other slide frameworks (Marp, Slidev, impress.js, remark and others) are
496
- recognised and refused with their name, before PinDeck changes anything.
538
+ - Decks made without reveal.js (a script of their own, Shower, deck.js, a hand-written deck): a deck
539
+ that names its slides with `<meta name="pindeck:slides">` (a CSS selector) and their size with
540
+ `<meta name="pindeck:size">` is edited like a reveal.js deck, with screenshots at the slide size and
541
+ one PDF page per slide (contributed by Zeyu Xu). Decks made with other slide frameworks (Marp,
542
+ Slidev, impress.js, remark and others) without these tags are recognised and refused with their
543
+ name, before PinDeck changes anything; the message says how to add the tags.
544
+ - Screenshots fill the picture: headless Chrome lays the page out shorter than its window, which
545
+ left the slide letterboxed in some Chrome versions (reported by Zeyu Xu).
497
546
  - Linux: pictures of PDF pages with Poppler, the PowerPoint import with slide pictures from
498
547
  LibreOffice, and waveforms of non-WAV audio with ffmpeg, each when installed.
499
548
 
package/lib/eids.mjs CHANGED
@@ -145,8 +145,97 @@ function find(node, pred) {
145
145
  return hit;
146
146
  }
147
147
 
148
- /* Slides are leaf <section>s inside .slides; vertical-stack containers are not slides. */
148
+ /* ---- Decks made without reveal.js ----------------------------------------------------------------
149
+ A deck names its slides with <meta name="pindeck:slides" content="main.deck > section.slide">, a CSS
150
+ selector, and its size with <meta name="pindeck:size" content="1440x1080">. Selectors stay simple, so
151
+ the server and the browser find the same slides: tag names, #id, .class, [attr] and [attr="value"],
152
+ joined by spaces (descendant) or ">" (child), in a comma-separated list. */
153
+
154
+ const metaOf = (tree, name) => {
155
+ const m = find(tree, (c) => c.name === "meta" && c.attrs.name?.value.trim().toLowerCase() === `pindeck:${name}`);
156
+ return m ? decode(m.attrs.content?.value || "").trim() || null : null;
157
+ };
158
+ /* The deck's slide selector, or null for a reveal.js deck */
159
+ export const slideSelector = (tree) => metaOf(tree, "slides");
160
+ /* The deck's slide size from <meta name="pindeck:size">: { width, height } or null */
161
+ export function deckSize(tree) {
162
+ const m = /^(\d{2,5})\s*[x×*,\s]\s*(\d{2,5})$/i.exec(metaOf(tree, "size") || "");
163
+ return m ? { width: +m[1], height: +m[2] } : null;
164
+ }
165
+
166
+ const IDENT = "-?[_a-zA-Z\\u00a0-\\uffff][\\w\\u00a0-\\uffff-]*";
167
+ const COMPOUND = new RegExp(`^(?:(\\*|${IDENT})|#(${IDENT})|\\.(${IDENT})|\\[\\s*(${IDENT})\\s*(?:=\\s*(?:"([^"]*)"|'([^']*)'|(${IDENT}))\\s*)?\\])`);
168
+ /* "main.deck > section.slide, aside.extra" -> one list of compounds and combinators per selector.
169
+ Throws with the part it cannot read (pseudo-classes, +, ~ ...). */
170
+ export function parseSelector(sel) {
171
+ const parts = []; // top-level commas only: not inside [attr="a,b"]
172
+ let quote = null, depth = 0, from = 0;
173
+ for (let i = 0; i < sel.length; i++) {
174
+ const c = sel[i];
175
+ if (quote) { if (c === quote) quote = null; }
176
+ else if (c === '"' || c === "'") quote = c;
177
+ else if (c === "[") depth++;
178
+ else if (c === "]") depth--;
179
+ else if (c === "," && !depth) { parts.push(sel.slice(from, i)); from = i + 1; }
180
+ }
181
+ parts.push(sel.slice(from));
182
+ return parts.map((part) => {
183
+ const steps = []; // [{ tag, ids, classes, attrs }, ">" | " ", { ... }, ...]
184
+ let s = part.trim(), cur = null;
185
+ if (!s) throw new Error(`empty selector in "${sel}"`);
186
+ while (s) {
187
+ const ws = /^\s+/.exec(s);
188
+ const gt = /^\s*>\s*/.exec(s);
189
+ if (gt) { if (!cur) throw new Error(`"${part.trim()}" starts with ">"`); steps.push(">"); cur = null; s = s.slice(gt[0].length); continue; }
190
+ if (ws) { steps.push(" "); cur = null; s = s.slice(ws[0].length); continue; }
191
+ const m = COMPOUND.exec(s);
192
+ if (!m) throw new Error(`"${s.slice(0, 20)}" in "${part.trim()}" is not supported (use tags, #id, .class, [attr], spaces and ">")`);
193
+ if (!cur) { cur = { tag: null, ids: [], classes: [], attrs: [] }; steps.push(cur); }
194
+ if (m[1]) {
195
+ if (cur.tag || cur.ids.length || cur.classes.length || cur.attrs.length) throw new Error(`a tag name must come first in "${part.trim()}"`);
196
+ cur.tag = m[1] === "*" ? null : m[1].toLowerCase();
197
+ }
198
+ else if (m[2]) cur.ids.push(m[2]);
199
+ else if (m[3]) cur.classes.push(m[3]);
200
+ else cur.attrs.push({ name: m[4].toLowerCase(), value: m[5] ?? m[6] ?? m[7] ?? null });
201
+ s = s.slice(m[0].length);
202
+ }
203
+ if (typeof steps.at(-1) === "string") throw new Error(`"${part.trim()}" ends with a combinator`);
204
+ return steps;
205
+ });
206
+ }
207
+ function compoundMatches(node, c) {
208
+ if (!node.attrs) return false; // the root
209
+ if (c.tag && node.name !== c.tag) return false;
210
+ if (c.ids.some((id) => node.attrs.id?.value !== id)) return false;
211
+ const cls = classesOf(node);
212
+ if (c.classes.some((k) => !cls.includes(k))) return false;
213
+ return c.attrs.every((a) => node.attrs[a.name] && (a.value == null || node.attrs[a.name].value === a.value));
214
+ }
215
+ /* steps[i] matches node, and the steps before it match its ancestors as their combinators say */
216
+ function stepsMatch(node, steps, i = steps.length - 1) {
217
+ if (!compoundMatches(node, steps[i])) return false;
218
+ if (i === 0) return true;
219
+ if (steps[i - 1] === ">") return !!node.parent && stepsMatch(node.parent, steps, i - 2);
220
+ for (let a = node.parent; a; a = a.parent) if (stepsMatch(a, steps, i - 2)) return true;
221
+ return false;
222
+ }
223
+ /* Elements matching the selector, in document order; an element holding another match is a stack of
224
+ slides, not a slide (as with reveal.js's vertical stacks) */
225
+ function selectorSlides(tree, sel) {
226
+ let list;
227
+ try { list = parseSelector(sel); } catch { return []; }
228
+ const hits = [];
229
+ walk(tree, (c) => { if (list.some((steps) => stepsMatch(c, steps))) hits.push(c); });
230
+ const inside = (a, b) => { for (let p = b.parent; p; p = p.parent) if (p === a) return true; return false; };
231
+ return hits.filter((h) => !hits.some((o) => o !== h && inside(h, o)));
232
+ }
233
+
234
+ /* Slides are leaf <section>s inside .slides; vertical-stack containers are not slides. A deck that names
235
+ its slides with <meta name="pindeck:slides"> has the elements that selector matches instead. */
149
236
  export function findSlides(tree) {
237
+ const sel = slideSelector(tree);
238
+ if (sel) return selectorSlides(tree, sel);
150
239
  const slidesEl = find(tree, (c) => classesOf(c).includes("slides"));
151
240
  if (!slidesEl) return [];
152
241
  const out = [];
@@ -187,6 +276,7 @@ function elementName(node) {
187
276
  export function assignEids(html) {
188
277
  const tree = parse(html);
189
278
  const sections = findSlides(tree);
279
+ const isSlide = slideSelector(tree) ? ((c) => sections.includes(c)) : ((c) => c.name === "section");
190
280
  const used = new Set();
191
281
  const edits = []; // { at, insert } or { start, end, replace }
192
282
  const assigned = [], renamed = [];
@@ -240,7 +330,7 @@ export function assignEids(html) {
240
330
  // Pass 2: elements inside the slide
241
331
  const visit = (node, inSvg) => {
242
332
  for (const c of node.children) {
243
- if (c.name === "section") continue; // nested slide, handled on its own
333
+ if (isSlide(c)) continue; // nested slide, handled on its own
244
334
  const cls = classesOf(c);
245
335
  if (c.name === "aside" && cls.includes("notes")) continue;
246
336
  if (c.attrs["data-noeid"]) continue;
@@ -423,10 +513,11 @@ const slideKind = (n) => classList(n).find((c) => /-slide$/.test(c)) || "content
423
513
  content title and two placeholder bullets, from a content slide (the current one if it is one)
424
514
  section a section divider, from the deck's first divider
425
515
  duplicate a copy of the current slide
426
- Element ids are left out; assignEids() gives the new slide fresh ones.
516
+ Element ids are left out; assignEids() gives the new slide fresh ones. In a deck made without reveal.js,
517
+ the slide's own id is left out too: such decks (and Present) go to slides by their id.
427
518
  Returns { html, index } (index of the new slide) or { error }. */
428
519
  export function insertSlide(html, kind, afterEid) {
429
- const slides = findSlides(parse(html));
520
+ const tree = parse(html), slides = findSlides(tree);
430
521
  if (!slides.length) return { error: "The deck has no slides." };
431
522
  const after = slides.find((s) => s.attrs["data-eid"]?.value === afterEid) || slides.at(-1);
432
523
  const hasHead = (s) => { let h = false; walk({ children: [s] }, (c) => { if (/^h[1-3]$/.test(c.name)) h = true; }); return h; };
@@ -437,6 +528,7 @@ export function insertSlide(html, kind, afterEid) {
437
528
  if (!tpl) return { error: kind === "section" ? "This deck has no section divider to model a new one on." : "This deck has no content slide to model a new one on." };
438
529
 
439
530
  let frag = html.slice(tpl.start, elementEnd(html, tpl)).replace(/\s+data-eid="[^"]*"/g, "");
531
+ if (slideSelector(tree)) frag = frag.replace(/^(<[^>]*?)\s+id=(?:"[^"]*"|'[^']*'|[^\s>]+)/, "$1"); // the opening tag only
440
532
  if (kind !== "duplicate") frag = placeholderSlide(frag, kind, slides.filter((s, i) => i <= slides.indexOf(after) && slideKind(s) === "section-slide").length + 1);
441
533
 
442
534
  const indent = lineIndent(html, after.start);
@@ -500,12 +592,13 @@ function slideBlock(html, s) {
500
592
  are. Slides inside vertical stacks are not moved. Returns { html, changed, from, index } or { error }. */
501
593
  export function moveSlide(html, eid, beforeEid) {
502
594
  const byEid = (all, e) => all.find((s) => s.attrs["data-eid"]?.value === e);
503
- const slides = findSlides(parse(html));
595
+ const tree = parse(html), slides = findSlides(tree);
596
+ const bySelector = !!slideSelector(tree); // such decks have no vertical stacks
504
597
  const slide = byEid(slides, eid), target = beforeEid == null ? null : byEid(slides, beforeEid);
505
598
  if (!slide || (beforeEid != null && !target)) return { error: "Slide not found in the file." };
506
- if ([slide, target].some((s) => s?.parent.name === "section")) return { error: "Slides inside a vertical stack cannot be moved yet." };
599
+ if (!bySelector && [slide, target].some((s) => s?.parent.name === "section")) return { error: "Slides inside a vertical stack cannot be moved yet." };
507
600
  const from = slides.indexOf(slide);
508
- const top = slide.parent.children.filter((c) => c.name === "section");
601
+ const top = slide.parent.children.filter((c) => (bySelector ? slides.includes(c) : c.name === "section"));
509
602
  const next = top[top.indexOf(slide) + 1] || null;
510
603
  if (target === slide || target === next) return { html, changed: false, from, index: from };
511
604
 
@@ -520,7 +613,7 @@ export function moveSlide(html, eid, beforeEid) {
520
613
  out = cut.slice(0, at) + text + `\n\n${indent}` + cut.slice(at);
521
614
  } else {
522
615
  // A final vertical stack is absent from the leaf-slide list, but we must insert after it.
523
- const last = find(parse(cut), (c) => classesOf(c).includes("slides"))
616
+ const last = bySelector ? rest.at(-1) : find(parse(cut), (c) => classesOf(c).includes("slides"))
524
617
  .children.filter((c) => c.name === "section").at(-1);
525
618
  const at = elementEnd(cut, last);
526
619
  out = cut.slice(0, at) + `\n\n${indent}` + text + cut.slice(at);
@@ -1,10 +1,11 @@
1
1
  /* Which slide framework a deck uses. PinDeck edits reveal.js decks: <section> slides inside an element
2
- with class "slides" (inside <div class="reveal">). Other frameworks are recognised by their markup and
3
- scripts, so the user gets a clear "not supported (yet)" before PinDeck changes anything, instead of an
4
- editor without slides. Only tags and scripts are looked at, never the slides' text, so a talk that
5
- mentions "impress.js" is not taken for one. */
2
+ with class "slides" (inside <div class="reveal">). Any other deck can name its slides itself with
3
+ <meta name="pindeck:slides" content="CSS selector"> (see lib/eids.mjs) and is then edited too. Other
4
+ frameworks are recognised by their markup and scripts, so the user gets a clear "not supported (yet)"
5
+ before PinDeck changes anything, instead of an editor without slides. Only tags and scripts are looked
6
+ at, never the slides' text, so a talk that mentions "impress.js" is not taken for one. */
6
7
 
7
- import { parse, findSlides } from "./eids.mjs";
8
+ import { parse, findSlides, slideSelector, deckSize, parseSelector } from "./eids.mjs";
8
9
 
9
10
  const OTHERS = [ // name, markers; the first match wins (Marp before bespoke.js, which it is built on)
10
11
  ["Marp", /\bmarpit\b|bespoke-marp|data-marpit|marp-cli/i],
@@ -20,9 +21,13 @@ const OTHERS = [ // name, markers; the first match wins (Marp before bespoke.j
20
21
  /* Tags and script contents only: the slides' text could name any framework */
21
22
  const markup = (html) => [...String(html).matchAll(/<script\b[^>]*>[\s\S]*?<\/script>|<[^>]+>/gi)].map((m) => m[0]).join("\n");
22
23
 
23
- /* { name, supported }: reveal.js decks are supported, including one without slides yet */
24
+ /* { name, supported }: reveal.js decks are supported, including one without slides yet, and so is a deck
25
+ whose <meta name="pindeck:slides"> selector finds slides and whose <meta name="pindeck:size"> gives their
26
+ size ({ name: "selector", selector, supported }) */
24
27
  export function detectFramework(html) {
25
- if (findSlides(parse(html)).length) return { name: "reveal.js", supported: true };
28
+ const tree = parse(html), selector = slideSelector(tree);
29
+ if (selector) return { name: "selector", selector, supported: findSlides(tree).length > 0 && !!deckSize(tree) };
30
+ if (findSlides(tree).length) return { name: "reveal.js", supported: true };
26
31
  const tags = markup(html);
27
32
  const other = OTHERS.find(([, re]) => re.test(tags));
28
33
  if (other) return { name: other[0], supported: false };
@@ -30,12 +35,25 @@ export function detectFramework(html) {
30
35
  return { name: null, supported: false };
31
36
  }
32
37
 
38
+ const HOW = `A deck made otherwise can name its slides in its <head>: ` +
39
+ `<meta name="pindeck:slides" content="main.deck > section.slide"> (a CSS selector for the slides) and ` +
40
+ `<meta name="pindeck:size" content="1440x1080"> (their size in px).`;
41
+
33
42
  /* The message for a deck PinDeck cannot edit, or null */
34
43
  export function unsupportedMessage(html, file) {
35
44
  const fw = detectFramework(html);
36
45
  if (fw.supported) return null;
46
+ if (fw.name === "selector") {
47
+ // the slides are found: the size is what is missing (headless Chrome and the PDF pages need it)
48
+ if (findSlides(parse(html)).length) return `${file}: <meta name="pindeck:size"> is missing or not a size ` +
49
+ `such as content="1440x1080" (the slides' width and height in px). PinDeck opens the slides at that size ` +
50
+ `for screenshots and PDFs. Nothing was changed.`;
51
+ let why = "matches no elements";
52
+ try { parseSelector(fw.selector); } catch (e) { why = `cannot be used: ${e.message}`; }
53
+ return `${file}: the slide selector "${fw.selector}" in <meta name="pindeck:slides"> ${why}. Nothing was changed.`;
54
+ }
37
55
  const what = fw.name ? `${file} is a ${fw.name} deck.` :
38
56
  `${file} has no reveal.js slides (a <div class="reveal"> with <div class="slides"> holding <section> slides).`;
39
- return `${what} PinDeck works with reveal.js decks only for now; other frameworks may follow. ` +
57
+ return `${what} PinDeck works with reveal.js decks; other frameworks may follow. ${HOW} ` +
40
58
  `Nothing was changed.`;
41
59
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pindeck",
3
- "version": "3.0.0-beta.2",
3
+ "version": "3.0.0-beta.3",
4
4
  "description": "PinDeck: an editor for HTML slide decks on macOS, and on Linux (beta) through an SSH tunnel. Pin elements, describe the change, and Claude Code or OpenAI Codex edits the slides.",
5
5
  "keywords": [
6
6
  "slides",
package/public/bridge.js CHANGED
@@ -4,7 +4,8 @@
4
4
  main the visible slide in the editor: navigation, selection, drag and resize, text editing, pins
5
5
  measure hidden 1280x720 copy used to measure any slide without moving the user's view
6
6
  shot headless screenshot of one slide, with pin markers
7
- dump headless measurement; writes the result into the DOM for --dump-dom */
7
+ dump headless measurement; writes the result into the DOM for --dump-dom
8
+ pdf print layout for the PDF export (decks made without reveal.js; reveal.js has its own) */
8
9
 
9
10
  (function () {
10
11
  const params = new URLSearchParams(location.search);
@@ -14,64 +15,69 @@
14
15
  const deckMeta = (n) => document.querySelector(`meta[name="pindeck:${n}"]`)?.content?.trim();
15
16
  const FOOTER_TOP = deckMeta("footer-top") ? +deckMeta("footer-top") : null;
16
17
  const LOCKED_SEL = deckMeta("locked") || ".slide-foot, .logo";
18
+ // A deck made without reveal.js names its slides with <meta name="pindeck:slides"> (a CSS selector)
19
+ const SLIDE_SEL = deckMeta("slides") || null;
20
+ const SLIDE = SLIDE_SEL ? `:is(${SLIDE_SEL})` : ".reveal .slides section"; // in CSS and closest()
21
+ const SLIDE_OF = SLIDE_SEL || "section"; // the slide an element is on
17
22
  const host = () => (window.parent !== window ? window.parent.__editorHost : null);
18
- let W = 1280, H = 720;
23
+ let W = 1280, H = 720, deck = null; // deck: reveal.js or a selector deck (see "Decks" below)
19
24
 
20
25
  /* ---- Styles for editor-only overlays ---------------------------------------- */
21
26
  const css = document.createElement("style");
22
- // --u = screen pixels per slide pixel, inverted: overlays keep a constant on-screen size at any zoom
27
+ // --pindeck-u = screen pixels per slide pixel, inverted: overlays keep a constant on-screen size at any
28
+ // zoom (a name of its own: decks use --u for their own units)
23
29
  css.textContent = `
24
- .__pin { position:absolute; z-index:1000; width:calc(26px*var(--u,1)); height:calc(26px*var(--u,1));
25
- margin:calc(-13px*var(--u,1)) 0 0 calc(-13px*var(--u,1)); border-radius:50%;
26
- background:#ED8C01; color:#fff; font:700 calc(14px*var(--u,1))/calc(26px*var(--u,1)) system-ui, sans-serif; text-align:center;
27
- box-shadow:0 0 0 calc(2px*var(--u,1)) #fff, 0 calc(2px*var(--u,1)) calc(6px*var(--u,1)) rgba(0,0,0,.45);
30
+ .__pin { position:absolute; z-index:1000; width:calc(26px*var(--pindeck-u,1)); height:calc(26px*var(--pindeck-u,1));
31
+ margin:calc(-13px*var(--pindeck-u,1)) 0 0 calc(-13px*var(--pindeck-u,1)); border-radius:50%;
32
+ background:#ED8C01; color:#fff; font:700 calc(14px*var(--pindeck-u,1))/calc(26px*var(--pindeck-u,1)) system-ui, sans-serif; text-align:center;
33
+ box-shadow:0 0 0 calc(2px*var(--pindeck-u,1)) #fff, 0 calc(2px*var(--pindeck-u,1)) calc(6px*var(--pindeck-u,1)) rgba(0,0,0,.45);
28
34
  cursor:pointer; pointer-events:auto; }
29
- .__hover { position:absolute; z-index:999; pointer-events:none; outline:calc(2px*var(--u,1)) dashed #ED8C01;
30
- outline-offset:calc(2px*var(--u,1)); background:rgba(237,140,1,.06); }
31
- .__hover span { position:absolute; left:calc(-2px*var(--u,1)); background:#ED8C01; color:#fff; white-space:nowrap;
32
- font:600 calc(11px*var(--u,1))/calc(17px*var(--u,1)) system-ui, sans-serif; padding:0 calc(5px*var(--u,1));
33
- border-radius:calc(3px*var(--u,1)); }
34
- .__hover span.above { top:calc(-21px*var(--u,1)); } .__hover span.below { bottom:calc(-21px*var(--u,1)); }
35
+ .__hover { position:absolute; z-index:999; pointer-events:none; outline:calc(2px*var(--pindeck-u,1)) dashed #ED8C01;
36
+ outline-offset:calc(2px*var(--pindeck-u,1)); background:rgba(237,140,1,.06); }
37
+ .__hover span { position:absolute; left:calc(-2px*var(--pindeck-u,1)); background:#ED8C01; color:#fff; white-space:nowrap;
38
+ font:600 calc(11px*var(--pindeck-u,1))/calc(17px*var(--pindeck-u,1)) system-ui, sans-serif; padding:0 calc(5px*var(--pindeck-u,1));
39
+ border-radius:calc(3px*var(--pindeck-u,1)); }
40
+ .__hover span.above { top:calc(-21px*var(--pindeck-u,1)); } .__hover span.below { bottom:calc(-21px*var(--pindeck-u,1)); }
35
41
  .__hover.locked { outline-color:#888; background:rgba(0,0,0,.04); }
36
42
  .__hover.locked span { background:#888; }
37
- .__flash { position:absolute; z-index:998; pointer-events:none; outline:calc(3px*var(--u,1)) solid #ED8C01;
38
- outline-offset:calc(3px*var(--u,1)); animation:__flash 1.4s ease-out forwards; }
43
+ .__flash { position:absolute; z-index:998; pointer-events:none; outline:calc(3px*var(--pindeck-u,1)) solid #ED8C01;
44
+ outline-offset:calc(3px*var(--pindeck-u,1)); animation:__flash 1.4s ease-out forwards; }
39
45
  @keyframes __flash { 0%,60% { opacity:1 } 100% { opacity:0 } }
40
- html.__pinmode .reveal .slides section, html.__pinmode .reveal .slides section * { cursor:crosshair !important; }
41
- html.__pinmode .reveal .slides section .__pin { cursor:pointer !important; }
42
- .__hover.sel { outline:calc(1px*var(--u,1)) solid #1a73e8; outline-offset:0; background:none; }
46
+ html.__pinmode ${SLIDE}, html.__pinmode ${SLIDE} * { cursor:crosshair !important; }
47
+ html.__pinmode ${SLIDE} .__pin { cursor:pointer !important; }
48
+ .__hover.sel { outline:calc(1px*var(--pindeck-u,1)) solid #1a73e8; outline-offset:0; background:none; }
43
49
  .__hover.sel span { background:#1a73e8; }
44
- .__sel { position:absolute; z-index:1001; pointer-events:none; outline:calc(1.5px*var(--u,1)) solid #1a73e8; }
45
- .__sel i { position:absolute; width:calc(9px*var(--u,1)); height:calc(9px*var(--u,1)); margin:calc(-5px*var(--u,1)) 0 0 calc(-5px*var(--u,1));
46
- background:#fff; border:calc(1.5px*var(--u,1)) solid #1a73e8; box-sizing:border-box; pointer-events:auto; }
50
+ .__sel { position:absolute; z-index:1001; pointer-events:none; outline:calc(1.5px*var(--pindeck-u,1)) solid #1a73e8; }
51
+ .__sel i { position:absolute; width:calc(9px*var(--pindeck-u,1)); height:calc(9px*var(--pindeck-u,1)); margin:calc(-5px*var(--pindeck-u,1)) 0 0 calc(-5px*var(--pindeck-u,1));
52
+ background:#fff; border:calc(1.5px*var(--pindeck-u,1)) solid #1a73e8; box-sizing:border-box; pointer-events:auto; }
47
53
  .__sel i[data-h="nw"] { left:0; top:0; cursor:nwse-resize; } .__sel i[data-h="n"] { left:50%; top:0; cursor:ns-resize; }
48
54
  .__sel i[data-h="ne"] { left:100%; top:0; cursor:nesw-resize; } .__sel i[data-h="e"] { left:100%; top:50%; cursor:ew-resize; }
49
55
  .__sel i[data-h="se"] { left:100%; top:100%; cursor:nwse-resize; } .__sel i[data-h="s"] { left:50%; top:100%; cursor:ns-resize; }
50
56
  .__sel i[data-h="sw"] { left:0; top:100%; cursor:nesw-resize; } .__sel i[data-h="w"] { left:0; top:50%; cursor:ew-resize; }
51
57
  .__sel.readonly i { display:none; }
52
- .__sel-label { position:absolute; left:calc(-1.5px*var(--u,1)); white-space:nowrap; background:#1a73e8; color:#fff;
53
- font:600 calc(11px*var(--u,1))/calc(17px*var(--u,1)) system-ui, sans-serif; padding:0 calc(5px*var(--u,1));
54
- border-radius:calc(3px*var(--u,1)); font-variant-numeric:tabular-nums; }
55
- .__sel-label.above { top:calc(-21px*var(--u,1)); } .__sel-label.below { bottom:calc(-21px*var(--u,1)); }
58
+ .__sel-label { position:absolute; left:calc(-1.5px*var(--pindeck-u,1)); white-space:nowrap; background:#1a73e8; color:#fff;
59
+ font:600 calc(11px*var(--pindeck-u,1))/calc(17px*var(--pindeck-u,1)) system-ui, sans-serif; padding:0 calc(5px*var(--pindeck-u,1));
60
+ border-radius:calc(3px*var(--pindeck-u,1)); font-variant-numeric:tabular-nums; }
61
+ .__sel-label.above { top:calc(-21px*var(--pindeck-u,1)); } .__sel-label.below { bottom:calc(-21px*var(--pindeck-u,1)); }
56
62
  .__guide { position:absolute; z-index:1002; pointer-events:none; background:#e5399d; }
57
- .__guide.v { top:0; height:100%; width:calc(1px*var(--u,1)); }
58
- .__guide.h { left:0; width:100%; height:calc(1px*var(--u,1)); }
59
- html.__selmode .reveal .slides section * { cursor:default; }
60
- html.__selmode .reveal .slides section .__selected, html.__selmode.__dragging * { cursor:move !important; }
61
- html.__selmode .reveal .slides section .__sel i { cursor:inherit; }
62
- .__editing { outline:calc(2px*var(--u,1)) solid #1a73e8 !important; outline-offset:calc(3px*var(--u,1));
63
+ .__guide.v { top:0; height:100%; width:calc(1px*var(--pindeck-u,1)); }
64
+ .__guide.h { left:0; width:100%; height:calc(1px*var(--pindeck-u,1)); }
65
+ html.__selmode ${SLIDE} * { cursor:default; }
66
+ html.__selmode ${SLIDE} .__selected, html.__selmode.__dragging * { cursor:move !important; }
67
+ html.__selmode ${SLIDE} .__sel i { cursor:inherit; }
68
+ .__editing { outline:calc(2px*var(--pindeck-u,1)) solid #1a73e8 !important; outline-offset:calc(3px*var(--pindeck-u,1));
63
69
  background:rgba(26,115,232,.04); cursor:text !important; caret-color:#1a73e8; }
64
70
  .__editing * { cursor:text !important; }
65
- .__hidden-banner { position:absolute; z-index:997; top:calc(26px*var(--u,1)); right:calc(26px*var(--u,1)); pointer-events:none;
66
- background:#ED8C01; color:#fff; font:600 calc(12px*var(--u,1))/calc(22px*var(--u,1)) system-ui, sans-serif;
67
- padding:0 calc(10px*var(--u,1)); border-radius:calc(11px*var(--u,1)); box-shadow:0 calc(2px*var(--u,1)) calc(8px*var(--u,1)) rgba(0,0,0,.25); }
71
+ .__hidden-banner { position:absolute; z-index:997; top:calc(26px*var(--pindeck-u,1)); right:calc(26px*var(--pindeck-u,1)); pointer-events:none;
72
+ background:#ED8C01; color:#fff; font:600 calc(12px*var(--pindeck-u,1))/calc(22px*var(--pindeck-u,1)) system-ui, sans-serif;
73
+ padding:0 calc(10px*var(--pindeck-u,1)); border-radius:calc(11px*var(--pindeck-u,1)); box-shadow:0 calc(2px*var(--pindeck-u,1)) calc(8px*var(--pindeck-u,1)) rgba(0,0,0,.25); }
68
74
  .__edit-hint { position:absolute; z-index:1003; pointer-events:none; white-space:nowrap; background:#1a73e8; color:#fff;
69
- font:600 calc(11px*var(--u,1))/calc(17px*var(--u,1)) system-ui, sans-serif; padding:0 calc(5px*var(--u,1));
70
- border-radius:calc(3px*var(--u,1)); }`;
75
+ font:600 calc(11px*var(--pindeck-u,1))/calc(17px*var(--pindeck-u,1)) system-ui, sans-serif; padding:0 calc(5px*var(--pindeck-u,1));
76
+ border-radius:calc(3px*var(--pindeck-u,1)); }`;
71
77
  document.head.appendChild(css);
72
78
 
73
79
  /* ---- Geometry ------------------------------------------------------------------ */
74
- const slides = () => Reveal.getSlides();
80
+ const slides = () => deck.slides();
75
81
  const indexOf = (sec) => slides().indexOf(sec);
76
82
  const r1 = (v) => Math.round(v);
77
83
  function frame(sec) {
@@ -122,7 +128,7 @@
122
128
  hasFooter: !!sec.querySelector(".slide-foot"), hidden };
123
129
  }
124
130
  function info() {
125
- return { slides: slides().map((s, i) => slideInfo(s, i)), current: indexOf(Reveal.getCurrentSlide()) };
131
+ return { slides: slides().map((s, i) => slideInfo(s, i)), current: indexOf(deck.current()) };
126
132
  }
127
133
 
128
134
  /* ---- Layout measurement ------------------------------------------------------------- */
@@ -138,7 +144,7 @@
138
144
  const hidden = cs.display === "none" || cs.visibility === "hidden" || +cs.opacity === 0 || (b.w === 0 && b.h === 0);
139
145
  const leaf = !el.querySelector("[data-eid]");
140
146
  const own = el === sec ? "" : textOf(el, !leaf);
141
- const tag = el.tagName.toLowerCase() + (el.classList.length ? "." + [...el.classList].filter((c) => !/^(fragment|visible|current-fragment|present|past|future)$/.test(c)).join(".") : "");
147
+ const tag = el.tagName.toLowerCase() + (el.classList.length ? "." + [...el.classList].filter((c) => !/^(fragment|visible|current-fragment|present|past|future)$/.test(c) && !c.startsWith("__")).join(".") : "");
142
148
  const e = { eid: el.dataset.eid || si.eid, tag: tag.replace(/\.$/, ""), depth, ...b };
143
149
  if (own && el.tagName !== "IMG" && el !== sec) e.font = fontOf(el);
144
150
  if (own) e.text = own;
@@ -218,13 +224,8 @@
218
224
  const nextFrame = () => Promise.resolve();
219
225
 
220
226
  /* ---- Navigation ------------------------------------------------------------------------ */
221
- function goto(index) {
222
- const sec = slides()[index];
223
- if (!sec) return;
224
- const ix = Reveal.getIndices(sec);
225
- Reveal.slide(ix.h, ix.v);
226
- }
227
- function indices(index) { const sec = slides()[index]; return sec ? Reveal.getIndices(sec) : { h: 0, v: 0 }; }
227
+ const goto = (index) => deck.goto(index);
228
+ const presentHash = (index) => deck.hash(index); // the slide's address in the deck opened on its own
228
229
 
229
230
  /* ---- Pins and hover ---------------------------------------------------------------------- */
230
231
  let mode = "select", pins = [], editable = true; // mode: "select" (drag, resize) or "pin"
@@ -249,12 +250,12 @@
249
250
  }
250
251
  function targetOf(t) {
251
252
  if (!t || !t.closest) return null;
252
- const sec = t.closest(".reveal .slides section");
253
+ const sec = t.closest(SLIDE);
253
254
  if (!sec || t.closest(".__pin, .__hover, .__sel, .__guide")) return null;
254
255
  const foot = t.closest(".slide-foot");
255
256
  const el = foot ? sec : t.closest("[data-eid]") || sec;
256
257
  const locked = foot ? "footer, set by brand.css (locked)" : el !== sec && el.matches(LOCKED_SEL) ? "positioned by brand.css (locked)" : null;
257
- return { sec: el === sec ? sec : el.closest("section"), el, locked, foot };
258
+ return { sec: el === sec ? sec : el.closest(SLIDE_OF), el, locked, foot };
258
259
  }
259
260
  function onPointerDown(e) {
260
261
  if (MODE !== "main" || e.button !== 0) return;
@@ -309,7 +310,7 @@
309
310
  document.querySelectorAll(".__pin").forEach((p) => p.remove());
310
311
  for (const p of pins) {
311
312
  const sec = byEid(p.slide.eid);
312
- if (!sec || sec.tagName !== "SECTION") continue;
313
+ if (!sec || !sec.matches(SLIDE_OF)) continue;
313
314
  let x = p.point.x, y = p.point.y;
314
315
  const el = p.eid !== p.slide.eid ? byEid(p.eid, sec) : null;
315
316
  if (el && el.getClientRects().length) {
@@ -328,8 +329,8 @@
328
329
  function highlight(eid) {
329
330
  const el = byEid(eid);
330
331
  if (!el) return;
331
- const sec = el.tagName === "SECTION" ? el : el.closest("section");
332
- if (sec !== Reveal.getCurrentSlide()) return;
332
+ const sec = el.closest(SLIDE_OF);
333
+ if (sec !== deck.current()) return;
333
334
  const b = boxOf(el, sec);
334
335
  const f = document.createElement("div");
335
336
  f.className = "__flash";
@@ -396,8 +397,8 @@
396
397
  }
397
398
  function selectEid(eid) {
398
399
  const el = eid && byEid(eid);
399
- const sec = el?.closest("section");
400
- if (!el || sec !== Reveal.getCurrentSlide()) return select(null, null);
400
+ const sec = el?.closest(SLIDE_OF);
401
+ if (!el || sec !== deck.current()) return select(null, null);
401
402
  select(el, sec);
402
403
  }
403
404
 
@@ -698,8 +699,8 @@
698
699
  const h = document.createElement("div");
699
700
  h.className = "__edit-hint";
700
701
  h.textContent = "Editing · Enter to save · Esc to cancel · Shift+Enter new line";
701
- Object.assign(h.style, b.y < 30 ? { left: b.x + "px", top: `calc(${b.y + b.h}px + 6px*var(--u,1))` }
702
- : { left: b.x + "px", top: `calc(${b.y}px - 22px*var(--u,1))` });
702
+ Object.assign(h.style, b.y < 30 ? { left: b.x + "px", top: `calc(${b.y + b.h}px + 6px*var(--pindeck-u,1))` }
703
+ : { left: b.x + "px", top: `calc(${b.y}px - 22px*var(--pindeck-u,1))` });
703
704
  editing.sec.appendChild(h);
704
705
  }
705
706
  function onEditKey(e) {
@@ -783,7 +784,7 @@
783
784
 
784
785
  /* A free id for a new bullet on the same slide ("summary/li-4"); the server double-checks. */
785
786
  function newBulletEid(el) {
786
- const prefix = el.closest("section").dataset.eid;
787
+ const prefix = el.closest(SLIDE_OF).dataset.eid;
787
788
  const used = new Set([...document.querySelectorAll("[data-eid]")].map((e) => e.dataset.eid));
788
789
  let id = `${prefix}/li`, k = 2;
789
790
  while (used.has(id)) id = `${prefix}/li-${k++}`;
@@ -843,18 +844,183 @@
843
844
  })));
844
845
  }
845
846
 
847
+ /* ---- Decks -----------------------------------------------------------------------------------------
848
+ What the editor needs from a deck: its slides, the current one, going to a slide, the slide size and
849
+ scale, and events when the slide or the scale changes. reveal.js provides all of it. */
850
+ function revealDeck() {
851
+ const ix = (index) => { const sec = Reveal.getSlides()[index]; return sec ? Reveal.getIndices(sec) : { h: 0, v: 0 }; };
852
+ return {
853
+ slides: () => Reveal.getSlides(),
854
+ current: () => Reveal.getCurrentSlide(),
855
+ goto(index) { if (Reveal.getSlides()[index]) { const i = ix(index); Reveal.slide(i.h, i.v); } },
856
+ hash(index) { const i = ix(index); return `#/${i.h}${i.v ? "/" + i.v : ""}`; },
857
+ size: () => ({ width: Reveal.getConfig().width, height: Reveal.getConfig().height }),
858
+ scale: () => Reveal.getScale() || 1,
859
+ on: (ev, fn) => Reveal.on(ev === "slide" ? "slidechanged" : ev, fn),
860
+ prepare() { // no transitions, every build step shown; the measure and headless copies fill their frame
861
+ const base = { transition: "none", backgroundTransition: "none", fragments: false };
862
+ if (MODE === "main") Reveal.configure({ ...base });
863
+ else Reveal.configure({ ...base, margin: 0, keyboard: false });
864
+ },
865
+ viewport(width, height) { // lay the deck out for this size whatever the window's
866
+ Object.assign(Reveal.getRevealElement().style, { width: width + "px", height: height + "px" });
867
+ Reveal.layout();
868
+ },
869
+ ready: (fn) => (Reveal.isReady() ? fn() : Reveal.on("ready", fn)),
870
+ keys() {}, // reveal.js's own keyboard handling stays
871
+ };
872
+ }
873
+
874
+ /* A deck made without reveal.js, whose slides <meta name="pindeck:slides"> names (and its size
875
+ <meta name="pindeck:size">). PinDeck shows the slides itself, one at a time and scaled to the frame,
876
+ over whatever the deck's own script does: the elements around the slides lose their transforms, a
877
+ backdrop covers the rest of the page, and the deck's script gets no keys while the editor has them.
878
+ The classes and styles added here live in the page only; the deck file is never written from the DOM. */
879
+ function selectorDeck() {
880
+ const root = document.documentElement;
881
+ const found = () => { const all = [...document.querySelectorAll(SLIDE_SEL)]; return all.filter((s) => !all.some((o) => o !== s && s.contains(o))); };
882
+ let list = [], cur = 0, scale = 1, sz = null, view = null;
883
+ const listeners = { slide: [], resize: [] };
884
+ const emit = (ev) => listeners[ev].forEach((fn) => fn());
885
+ function mark() { // the slides, and the elements between them and <body>
886
+ list = found();
887
+ for (const s of list) {
888
+ s.classList.add("__pindeck_slide");
889
+ for (let a = s.parentElement; a && a !== document.body && a !== root; a = a.parentElement) a.classList.add("__pindeck_anc");
890
+ }
891
+ }
892
+ function size() { // the first call also finds the slides
893
+ if (sz) return sz;
894
+ mark();
895
+ // the server refuses a deck without a readable size, so the fallback is only for a tag removed meanwhile
896
+ const m = /^(\d{2,5})\s*[x×*,\s]\s*(\d{2,5})$/i.exec(deckMeta("size") || "");
897
+ sz = m ? { width: +m[1], height: +m[2] } : { width: 1280, height: 720 };
898
+ return sz;
899
+ }
900
+ // what takes a slide and its containers out of the deck's own layout (scaling, stacking, page views)
901
+ const LOOSE = "transform:none !important; translate:none !important; scale:none !important; rotate:none !important; " +
902
+ "zoom:1 !important; filter:none !important; backdrop-filter:none !important; perspective:none !important; " +
903
+ "contain:none !important; will-change:auto !important; opacity:1 !important; isolation:auto !important; " +
904
+ "mix-blend-mode:normal !important; clip-path:none !important; transition:none !important;";
905
+ function addStyle(text) { const st = document.createElement("style"); st.textContent = text; document.head.appendChild(st); }
906
+ function fit() {
907
+ const { width, height } = size(), m = MODE === "main" ? 0.04 : 0; // the margin reveal.js leaves in the editor
908
+ const vw = view?.width ?? innerWidth, vh = view?.height ?? innerHeight;
909
+ scale = Math.min(vw * (1 - m) / width, vh * (1 - m) / height) || 1;
910
+ root.style.setProperty("--pindeck-s", String(scale));
911
+ root.style.setProperty("--pindeck-x", `${(vw - width * scale) / 2}px`);
912
+ root.style.setProperty("--pindeck-y", `${(vh - height * scale) / 2}px`);
913
+ }
914
+ // a slide the deck's own CSS hides with display: none is shown as a block
915
+ function display(s) { s.classList.remove("__pindeck_block"); if (getComputedStyle(s).display === "none") s.classList.add("__pindeck_block"); }
916
+ function show(i) {
917
+ const s = list[i];
918
+ if (!s) return false;
919
+ const changed = i !== cur;
920
+ list[cur]?.classList.remove("__pindeck_cur");
921
+ cur = i;
922
+ s.classList.add("__pindeck_cur");
923
+ s.removeAttribute("inert"); s.removeAttribute("hidden"); // the deck's script marks the slides it does not show
924
+ display(s);
925
+ return changed;
926
+ }
927
+ return {
928
+ slides: () => list,
929
+ current: () => list[cur],
930
+ goto(index) { if (show(index)) emit("slide"); },
931
+ hash(index) { const s = list[index]; return s?.id ? `#${encodeURIComponent(s.id)}` : `#${index + 1}`; },
932
+ size,
933
+ scale: () => scale,
934
+ on: (ev, fn) => listeners[ev]?.push(fn),
935
+ prepare() {
936
+ const { width, height } = size();
937
+ addStyle(`
938
+ html.__pindeck_deck, html.__pindeck_deck body { overflow:hidden !important; }
939
+ html.__pindeck_deck .__pindeck_anc { ${LOOSE} position:static !important; z-index:auto !important; }
940
+ html.__pindeck_deck .__pindeck_slide:not(.__pindeck_cur) { visibility:hidden !important; }
941
+ html.__pindeck_deck .__pindeck_slide.__pindeck_cur { ${LOOSE} position:fixed !important; inset:0 auto auto 0 !important;
942
+ margin:0 !important; width:${width}px !important; height:${height}px !important; min-width:0 !important; min-height:0 !important;
943
+ max-width:none !important; max-height:none !important; transform-origin:0 0 !important;
944
+ transform:translate(var(--pindeck-x, 0px), var(--pindeck-y, 0px)) scale(var(--pindeck-s, 1)) !important;
945
+ visibility:visible !important; z-index:2147483001 !important; }
946
+ html.__pindeck_deck .__pindeck_slide.__pindeck_block { display:block !important; }
947
+ html.__pindeck_deck .__pindeck_backdrop { position:fixed; inset:0; z-index:2147483000; background:var(--pindeck-bg, #fff); }
948
+ html.__pindeck_deck aside.notes { display:none !important; }`);
949
+ // the backdrop has the page's own background
950
+ const bg = [document.body, root].map((e) => getComputedStyle(e).backgroundColor).find((c) => !/^(transparent|rgba\(.*,\s*0\))$/.test(c));
951
+ if (bg) root.style.setProperty("--pindeck-bg", bg);
952
+ const backdrop = document.createElement("div");
953
+ backdrop.className = "__pindeck_backdrop";
954
+ document.body.appendChild(backdrop);
955
+ root.classList.add("__pindeck_deck");
956
+ fit();
957
+ show(cur);
958
+ addEventListener("resize", () => { fit(); emit("resize"); });
959
+ // the deck's script may hide the slide again (when it changes slides itself)
960
+ new MutationObserver((recs) => { if (recs.some((r) => r.target === list[cur])) { list[cur].removeAttribute("inert"); list[cur].removeAttribute("hidden"); display(list[cur]); } })
961
+ .observe(document.body, { subtree: true, attributes: true, attributeFilter: ["inert", "hidden", "style"] });
962
+ },
963
+ viewport(width, height) { view = { width, height }; fit(); }, // lay the slide out for this size whatever the window's
964
+ ready: (fn) => (document.readyState === "complete" ? fn() : addEventListener("load", () => fn(), { once: true })),
965
+ keys() { // after the editor's own key handler: slide keys change the slide here, and the deck's script gets none
966
+ const field = (t) => t.closest?.("input, textarea, select, [contenteditable]");
967
+ const STEP = { ArrowRight: 1, ArrowDown: 1, PageDown: 1, ArrowLeft: -1, ArrowUp: -1, PageUp: -1 };
968
+ addEventListener("keydown", (e) => {
969
+ if (field(e.target)) return;
970
+ if (!e.metaKey && !e.ctrlKey && !e.altKey) {
971
+ const k = e.key, step = k === " " ? (e.shiftKey ? -1 : 1) : STEP[k];
972
+ const to = step ? cur + step : k === "Home" ? 0 : k === "End" ? list.length - 1 : null;
973
+ if (to != null) { e.preventDefault(); this.goto(Math.max(0, Math.min(list.length - 1, to))); }
974
+ }
975
+ e.stopImmediatePropagation();
976
+ }, true);
977
+ for (const t of ["keyup", "keypress"]) addEventListener(t, (e) => { if (!field(e.target)) e.stopImmediatePropagation(); }, true);
978
+ },
979
+ /* ?editor=pdf: every slide as a page of its own size, for PinDeck's PDF export (lib/pdf.mjs prints
980
+ this page's DOM as it is once laid out, so the layout is all CSS) */
981
+ print() {
982
+ const { width, height } = size();
983
+ const all = params.get("showHiddenSlides") === "true";
984
+ const shown = list.filter((s) => all || s.getAttribute("data-visibility") !== "hidden");
985
+ for (const s of list) { s.classList.add("pdf-page"); if (!shown.includes(s)) s.classList.add("__pindeck_skip"); }
986
+ shown.at(-1)?.classList.add("__pindeck_last");
987
+ const OUT = ":not(.__pindeck_anc):not(.__pindeck_slide)";
988
+ addStyle(`
989
+ @page { size:${width}px ${height}px; margin:0; }
990
+ html.__pindeck_print, html.__pindeck_print body { margin:0 !important; padding:0 !important; width:auto !important;
991
+ height:auto !important; overflow:visible !important; }
992
+ html.__pindeck_print .__pindeck_anc { ${LOOSE} position:static !important; display:block !important; width:auto !important;
993
+ height:auto !important; margin:0 !important; padding:0 !important; border:0 !important; overflow:visible !important; }
994
+ html.__pindeck_print body > ${OUT}:not(svg), html.__pindeck_print .__pindeck_anc > ${OUT}:not(svg) { display:none !important; }
995
+ html.__pindeck_print body > svg${OUT}, html.__pindeck_print .__pindeck_anc > svg${OUT} { position:absolute !important;
996
+ width:0 !important; height:0 !important; overflow:hidden !important; }
997
+ html.__pindeck_print .__pindeck_slide { ${LOOSE} position:relative !important; inset:auto !important; margin:0 !important;
998
+ width:${width}px !important; height:${height}px !important; max-width:none !important; max-height:none !important;
999
+ visibility:visible !important; overflow:hidden !important; break-inside:avoid; break-after:page; page-break-after:always; }
1000
+ html.__pindeck_print .__pindeck_slide.__pindeck_last { break-after:auto; page-break-after:auto; }
1001
+ html.__pindeck_print .__pindeck_slide.__pindeck_block { display:block !important; }
1002
+ html.__pindeck_print .__pindeck_skip { display:none !important; }
1003
+ html.__pindeck_print aside.notes { display:none !important; }`);
1004
+ root.classList.add("__pindeck_print");
1005
+ shown.forEach(display);
1006
+ },
1007
+ };
1008
+ }
1009
+
846
1010
  /* ---- Start ------------------------------------------------------------------------------------- */
847
1011
  async function start() {
848
- W = Reveal.getConfig().width; H = Reveal.getConfig().height;
849
- const base = { transition: "none", backgroundTransition: "none", fragments: false };
850
- const setUnit = () => document.documentElement.style.setProperty("--u", String(1 / (Reveal.getScale() || 1)));
851
- Reveal.on("resize", setUnit);
852
- if (MODE === "main") Reveal.configure({ ...base });
853
- else Reveal.configure({ ...base, margin: 0, keyboard: false });
1012
+ ({ width: W, height: H } = deck.size());
1013
+ const setUnit = () => document.documentElement.style.setProperty("--pindeck-u", String(1 / (deck.scale() || 1)));
1014
+ deck.on("resize", setUnit);
1015
+ deck.prepare();
854
1016
  setUnit();
855
1017
  await document.fonts.ready;
856
1018
 
857
1019
  if (MODE === "shot" || MODE === "dump") {
1020
+ // the size of headless Chrome's window, which the page itself gets only once the screenshot is
1021
+ // taken: lay the deck out for it now rather than letterboxed into the shorter viewport
1022
+ const vw = +params.get("width"), vh = +params.get("height");
1023
+ if (vw > 0 && vh > 0) { deck.viewport(vw, vh); setUnit(); }
858
1024
  const sec = byEid(params.get("slide") || "") || slides()[0];
859
1025
  goto(indexOf(sec));
860
1026
  await nextFrame();
@@ -872,11 +1038,11 @@
872
1038
  }
873
1039
 
874
1040
  const api = {
875
- info, goto, indices, highlight, reloadCss,
1041
+ info, goto, presentHash, highlight, reloadCss,
876
1042
  setMode, selectEid, nudge, editSelection,
877
1043
  // a new slide: select its title and open it for typing
878
1044
  editTitle() {
879
- const sec = Reveal.getCurrentSlide();
1045
+ const sec = deck.current();
880
1046
  const h = sec?.querySelector("h1[data-eid], h2[data-eid], h3[data-eid]");
881
1047
  if (!h || mode !== "select") return;
882
1048
  select(h, sec);
@@ -942,22 +1108,35 @@
942
1108
  host()?.key(k, { meta, shift: e.shiftKey });
943
1109
  }
944
1110
  }, true);
945
- Reveal.on("slidechanged", () => { if (editing) finishEdit(true); hideHover(); if (sel) select(null, null); host()?.slideChanged(indexOf(Reveal.getCurrentSlide())); });
946
- Reveal.on("resize", () => { if (sel) drawSel(); });
1111
+ deck.on("slide", () => {
1112
+ if (editing) finishEdit(true); hideHover(); if (sel) select(null, null);
1113
+ // a deck made without reveal.js lays out only the current slide at the slide size: pins on the
1114
+ // others were placed in another layout
1115
+ if (SLIDE_SEL) renderPins(pins);
1116
+ host()?.slideChanged(indexOf(deck.current()));
1117
+ });
1118
+ deck.on("resize", () => { if (sel) drawSel(); });
947
1119
  setMode("select");
948
1120
  // a banner on hidden slides, so nobody edits one without noticing (only in the editor view)
949
1121
  for (const sec of slides()) {
950
1122
  if (sec.getAttribute("data-visibility") !== "hidden") continue;
951
1123
  const b = document.createElement("div");
952
1124
  b.className = "__hidden-banner";
953
- b.textContent = "Hidden slide · skipped when presenting and in PDFs";
1125
+ b.textContent = SLIDE_SEL ? "Hidden slide · skipped in PDFs" : "Hidden slide · skipped when presenting and in PDFs";
954
1126
  sec.appendChild(b);
955
1127
  }
956
1128
  const startAt = +params.get("start");
957
1129
  if (startAt) goto(startAt);
1130
+ deck.keys();
958
1131
  }
959
1132
  host()?.bridgeReady(MODE, api, window);
960
1133
  }
961
- if (typeof Reveal === "undefined") return;
962
- if (Reveal.isReady()) start(); else Reveal.on("ready", start);
1134
+ if (SLIDE_SEL) {
1135
+ deck = selectorDeck();
1136
+ deck.ready(() => (MODE === "pdf" ? deck.print() : start()));
1137
+ return;
1138
+ }
1139
+ if (typeof Reveal === "undefined" || MODE === "pdf") return; // reveal.js lays out its own print view
1140
+ deck = revealDeck();
1141
+ deck.ready(start);
963
1142
  })();
package/public/editor.js CHANGED
@@ -229,7 +229,8 @@ function renderSlides() {
229
229
  ${s.kind !== "content" ? `<span class="kind">${esc(s.kind.replace(/-slide$/, ""))}</span>` : ""}
230
230
  ${s.hidden ? '<span class="kind hid">hidden</span>' : ""}</span>
231
231
  ${counts[i] ? `<span class="badge" title="${counts[i]} pin(s)">${counts[i]}</span>` : "<span></span>"}
232
- <button class="eye" data-eye="${i}" title="${s.hidden ? "Show this slide again" : "Hide this slide (skipped when presenting)"}"
232
+ <button class="eye" data-eye="${i}" title="${s.hidden ? "Show this slide again" : state.status?.slideSelector ?
233
+ "Hide this slide (left out of PDFs)" : "Hide this slide (skipped when presenting)"}"
233
234
  aria-label="${s.hidden ? "Show" : "Hide"} slide ${i + 1}">${s.hidden ? EYE_ICON : EYE_OFF_ICON}</button>
234
235
  </li>`).join("");
235
236
  $("slide-list").querySelector("li.active")?.scrollIntoView({ block: "nearest" });
@@ -492,8 +493,7 @@ $("export-menu").addEventListener("click", async (e) => {
492
493
  });
493
494
 
494
495
  $("present").onclick = () => {
495
- const ix = main?.bridge.indices(state.current) || { h: 0, v: 0 };
496
- window.open(`/deck/#/${ix.h}${ix.v ? "/" + ix.v : ""}`, "_blank");
496
+ window.open(`/deck/${main?.bridge.presentHash(state.current) || ""}`, "_blank");
497
497
  };
498
498
 
499
499
  /* ---- Pins --------------------------------------------------------------------------------------- */
@@ -739,6 +739,7 @@ document.addEventListener("keydown", (e) => {
739
739
  });
740
740
 
741
741
  /* ---- Server events ---------------------------------------------------------------------------------- */
742
+ let shownProblem = null; // the deck problem last shown, so a status update does not repeat it
742
743
  function applyStatus(s) {
743
744
  state.status = s;
744
745
  // the deck's name in the header ("ocean-example", or "talk.html" for a named deck file), its full path on hover
@@ -785,6 +786,9 @@ function applyStatus(s) {
785
786
  $("server-note").textContent = caps.label ? `Server: ${caps.label}` : "";
786
787
  $("server-note").title = off.length ? `Not available on this server:\n${off.join("\n")}\n\nWhy:\n${reasons.join("\n")}` : "";
787
788
  $("add-slide-btn").disabled = !!s.busy;
789
+ // the deck's slide tags stopped working while PinDeck runs (at start, such a deck is refused)
790
+ if (s.deckProblem && s.deckProblem !== shownProblem) toast(s.deckProblem, { ms: 20000 });
791
+ shownProblem = s.deckProblem;
788
792
  renderToolbar();
789
793
  }
790
794
  function connect() {
package/server.mjs CHANGED
@@ -24,7 +24,7 @@ import path from "node:path";
24
24
  import crypto from "node:crypto";
25
25
  import { spawn, execFile } from "node:child_process";
26
26
  import { fileURLToPath, pathToFileURL } from "node:url";
27
- import { assignEids, parse, findSlides, setStyles, getInner, setInner, insertAfter, removeElement, deleteTarget, fileRefs, insertSlide, moveSlide, getNotes, setNotes, setAttr, elementEnd } from "./lib/eids.mjs";
27
+ import { assignEids, parse, findSlides, slideSelector, deckSize, setStyles, getInner, setInner, insertAfter, removeElement, deleteTarget, fileRefs, insertSlide, moveSlide, getNotes, setNotes, setAttr, elementEnd } from "./lib/eids.mjs";
28
28
  import { unifiedDiff } from "./lib/diff.mjs";
29
29
  import { ClaudeSession } from "./lib/claude.mjs";
30
30
  import { CodexSession } from "./lib/codex.mjs";
@@ -45,7 +45,8 @@ const USAGE = `Usage: pindeck [deck-folder | deck-file.html] [--port 5180] [--pr
45
45
  pindeck login [deck-folder | deck-file.html]
46
46
 
47
47
  Opens a reveal.js deck in PinDeck: a deck file such as talk.html, or a folder (default: the current
48
- folder), which then opens its index.html.
48
+ folder), which then opens its index.html. A deck made otherwise names its slides in its <head> with
49
+ <meta name="pindeck:slides" content="<CSS selector>"> and <meta name="pindeck:size" content="1440x1080">.
49
50
  login prints a new one-time login link for the PinDeck running for that deck
50
51
  (without a deck: for the PinDeck started last).
51
52
  --asset-root lets the deck use files outside its folder: a folder that contains the deck (paths such as
@@ -126,8 +127,9 @@ if (deckProblem) {
126
127
  console.error(`${deckProblem}\n\n${USAGE}`);
127
128
  process.exit(1);
128
129
  }
129
- // only reveal.js for now: another framework's deck is left exactly as it is (no ids, no state)
130
- const frameworkProblem = unsupportedMessage(fs.readFileSync(path.join(DECK_DIR, DECK_FILE), "utf8"), DECK_FILE);
130
+ // reveal.js, or a deck that names its slides: another framework's deck is left exactly as it is (no ids, no state)
131
+ const deckAtStart = fs.readFileSync(path.join(DECK_DIR, DECK_FILE), "utf8");
132
+ const frameworkProblem = unsupportedMessage(deckAtStart, DECK_FILE);
131
133
  if (frameworkProblem) {
132
134
  console.error(frameworkProblem);
133
135
  process.exit(1);
@@ -159,6 +161,22 @@ const MIME = { html: "text/html; charset=utf-8", css: "text/css; charset=utf-8",
159
161
  mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", pdf: "application/pdf", md: "text/plain; charset=utf-8" };
160
162
  const hash = (s) => crypto.createHash("sha1").update(s).digest("hex");
161
163
  const readDeck = (f) => { try { return fs.readFileSync(path.join(DECK_DIR, f), "utf8"); } catch { return null; } };
164
+ /* A deck made without reveal.js names its slides with <meta name="pindeck:slides"> (null for reveal.js) and
165
+ their size with <meta name="pindeck:size">. Read from the deck as it is now, so a changed tag counts from
166
+ the next headless run or Claude session. Headless Chrome opens such a deck at the slide size, so a
167
+ screenshot is the slide itself. */
168
+ function deckSlides() {
169
+ const tree = parse(readDeck(DECK_FILE) || "");
170
+ const selector = slideSelector(tree);
171
+ return { selector, size: (selector && deckSize(tree)) || { width: 1280, height: 720 } };
172
+ }
173
+ /* Why the deck no longer names slides PinDeck can use (a tag changed while PinDeck runs), or null: the
174
+ start-up check's message, which the editor shows and Claude is told */
175
+ function deckProblemNow() {
176
+ const html = readDeck(DECK_FILE);
177
+ return (html != null && unsupportedMessage(html, DECK_FILE)?.replace(/ Nothing was changed\.$/, "")) || null;
178
+ }
179
+ let deckNow = { selector: deckSlides().selector, problem: null }; // for the editor; kept current by scheduleFlush
162
180
  const ourWrites = new Map(); // file -> { hash, manual } of what we wrote, to tell our writes from others
163
181
  function writeDeck(f, content, manual = false) {
164
182
  ourWrites.set(f, { hash: hash(content), manual });
@@ -283,15 +301,25 @@ const CHROME = CAPS.chromePath; // null when Chrome features are unavailable (
283
301
  /* Headless Chrome on macOS often lingers long after it has written its output, so we
284
302
  watch the output and stop Chrome as soon as `done(stdout, stderr)` says we have it. */
285
303
  let chromeSeq = 0;
304
+ /* The page gets the window's size too. Chrome lays it out about 87 px shorter than the window (room for
305
+ a toolbar it does not draw) and grows it only for the screenshot itself, too late for some versions
306
+ to lay the slide out again, so shot and dump lay the deck out for this size (see bridge.js). */
307
+ function sized(url, size) {
308
+ const u = new URL(url);
309
+ u.searchParams.set("width", size.width);
310
+ u.searchParams.set("height", size.height);
311
+ return u.href;
312
+ }
286
313
  function chrome(args, done) {
287
314
  if (!CHROME) return Promise.reject(new Error(`Screenshots are not available: ${CAPS.screenshot.reason}.`));
288
315
  return new Promise((resolve, reject) => {
289
316
  // a profile per run: concurrent runs must not share one
290
317
  const profile = path.join(STATE_DIR, "chrome", `p${process.pid}-${++chromeSeq}`);
318
+ const { size } = deckSlides();
291
319
  const proc = spawn(CHROME, ["--headless=new", "--disable-gpu", "--hide-scrollbars", "--no-first-run",
292
320
  "--no-default-browser-check", "--disable-extensions", "--mute-audio", `--user-data-dir=${profile}`,
293
- "--window-size=1280,720", "--force-device-scale-factor=1", "--virtual-time-budget=5000",
294
- ...args.map((a) => a.startsWith(ORIGIN + "/") ? chromeStart(profile, a) : a)]);
321
+ `--window-size=${size.width},${size.height}`, "--force-device-scale-factor=1", "--virtual-time-budget=5000",
322
+ ...args.map((a) => a.startsWith(ORIGIN + "/") ? chromeStart(profile, sized(a, size)) : a)]);
295
323
  let out = "", err = "", finished = false;
296
324
  const finish = (fn, v) => {
297
325
  if (finished) return;
@@ -467,7 +495,8 @@ let catalog = { claude: { models: [] }, codex: { models: [] } }, discovering = n
467
495
  let session = null, starting = null;
468
496
  const status = () => ({ busy: state.busy, switching: state.switching, model: state.model, sessionId: state.sessionId, settings: { ...settings },
469
497
  running: !!session?.running, provider: settings.provider, models: catalog, deck: DECK_DIR, deckFile: DECK_FILE,
470
- chrome: CAPS.screenshot.available, capabilities: publicCapabilities(CAPS), rev: deckRev(),
498
+ chrome: CAPS.screenshot.available, slideSelector: deckNow.selector, deckProblem: deckNow.problem,
499
+ capabilities: publicCapabilities(CAPS), rev: deckRev(),
471
500
  undo: changeLabel(liveTurns().at(-1)), redo: changeLabel(state.redo.at(-1)) });
472
501
  const pushStatus = () => broadcast("status", status());
473
502
 
@@ -479,6 +508,15 @@ const systemPrompt = fs.readFileSync(path.join(EDITOR_DIR, "lib/system-prompt.md
479
508
  (ASSET_ROOTS.length ? `\n\n## Files outside the deck folder\nThe deck may use files in ${ASSET_ROOTS.join(", ")}: refer to ` +
480
509
  `them with paths relative to ${DECK_FILE} (such as ../shared/style.css). You can read them; do not edit them, ` +
481
510
  `other decks may use them too.\n` : "");
511
+ /* For a deck made without reveal.js: what its slides are, from its selector as it is at each session start */
512
+ function slidesInstructions() {
513
+ const { selector } = deckSlides();
514
+ return selector ? `\n\n## Slides of this deck\nThis deck is not made with reveal.js: its slides are the elements matching the ` +
515
+ `CSS selector \`${selector}\` (<meta name="pindeck:slides">), and the deck's own script shows them. Where these ` +
516
+ `instructions say <section> or reveal.js, read an element matching that selector, and that script. A new slide is ` +
517
+ `a new such element next to the others; keep the pindeck meta tags. Hidden slides (data-visibility="hidden") are ` +
518
+ `left out of PinDeck's PDF exports; whether presenting skips them depends on the deck's script.\n` : "";
519
+ }
482
520
  /* What Claude may never change, whatever the edit rules allow: the locked brand files, vendored libraries,
483
521
  git's own files, and other decks in this folder (a named deck shares its folder with them). Deny rules
484
522
  win over allow rules; a locked file changed anyway is restored after the turn (lock 2 in finishTurn). */
@@ -515,7 +553,7 @@ When context mentions Read, use pindeck_read. Read screenshots with pindeck_read
515
553
  Read README.md and the deck instructions below for conventions. Use layout after every visual change.
516
554
  PDF tables should be reconstructed as HTML; pdf-figure renders figures. Audio is embedded by path.
517
555
 
518
- ## Hand edits`) + deckInstructions();
556
+ ## Hand edits`) + slidesInstructions() + deckInstructions();
519
557
  function makeSession(choice) {
520
558
  let initial = null;
521
559
  const options = {
@@ -535,7 +573,7 @@ function makeSession(choice) {
535
573
  explicit), PinDeck's own commands, never the locked paths. Deny rules win over allow rules. */
536
574
  const agent = choice.provider === "codex" ? new CodexSession({ ...options, settings: choice,
537
575
  instructions: codexInstructions(), tools: agentTools }) : new ClaudeSession({ ...options,
538
- args: () => ["--append-system-prompt", systemPrompt + deckInstructions(),
576
+ args: () => ["--append-system-prompt", systemPrompt + slidesInstructions() + deckInstructions(),
539
577
  "--setting-sources", "", "--strict-mcp-config", "--safe-mode", "--disable-slash-commands", // no settings files, no MCP servers or connectors
540
578
  "--permission-mode", "default", "--tools", "Read,Glob,Grep,Edit,Write,Bash",
541
579
  "--allowedTools", "Read", "Glob", "Grep", "Edit(./**)", "Write(./**)",
@@ -867,6 +905,14 @@ function scheduleFlush() {
867
905
  };
868
906
  eidTimer = setTimeout(pass, 800);
869
907
  }
908
+ if (files.includes(DECK_FILE)) { // the slide tags, by hand or by Claude: tell both when they stop working
909
+ const now = { selector: deckSlides().selector, problem: deckProblemNow() };
910
+ if (now.selector !== deckNow.selector || now.problem !== deckNow.problem) {
911
+ if (now.problem) state.notes = [...new Set([...state.notes, `PinDeck cannot use the deck's slides now: ${now.problem}`])];
912
+ deckNow = now;
913
+ pushStatus();
914
+ }
915
+ }
870
916
  // manual: only drag/resize writes, which the browser already shows; no reload of the visible slide
871
917
  broadcast("files", { files, manual, rev: deckRev() });
872
918
  }, 120);
@@ -1254,7 +1300,11 @@ const server = http.createServer(async (req, res) => {
1254
1300
  return serveFile(res, f, (s) => {
1255
1301
  // headless runs must not stream media, or Chrome never finishes the page
1256
1302
  if (mode === "shot" || mode === "dump" || mode === "pdf") s = s.replace(/\bpreload="(auto|metadata)"/g, 'preload="none"');
1257
- if (mode === "pdf") return s.replace(/<\/head>/i, PDF_HEAD_TAG).replace(/<\/body>/i, PDF_PAGE_TAG);
1303
+ if (mode === "pdf") {
1304
+ s = s.replace(/<\/head>/i, PDF_HEAD_TAG).replace(/<\/body>/i, PDF_PAGE_TAG);
1305
+ // a deck that names its slides (in the file as it is now) is laid out by the bridge; reveal.js lays out its own
1306
+ return slideSelector(parse(s)) ? s.replace(/<\/body>/i, BRIDGE_TAG) : s;
1307
+ }
1258
1308
  return s.replace(/<\/body>/i, BRIDGE_TAG);
1259
1309
  });
1260
1310
  }
@@ -1371,14 +1421,16 @@ const server = http.createServer(async (req, res) => {
1371
1421
  const b = await body(req);
1372
1422
  if (staleEdit(res, b)) return;
1373
1423
  const before = snapshot(EDITABLE);
1374
- const slides = findSlides(parse(before[DECK_FILE]));
1424
+ const tree = parse(before[DECK_FILE]), slides = findSlides(tree);
1375
1425
  const index = slides.findIndex((s) => s.attrs["data-eid"]?.value === b.eid);
1376
1426
  if (index < 0) return json(res, 404, { error: "Slide not found in the file." });
1377
1427
  const r = setAttr(before[DECK_FILE], b.eid, "data-visibility", b.hidden ? "hidden" : null);
1378
1428
  if (!r.changed) return json(res, 200, { unchanged: true });
1429
+ const skipped = slideSelector(tree) ? "left out of PDF exports (whether presenting skips them depends on the deck's script)"
1430
+ : "skipped when presenting and in PDF exports";
1379
1431
  const turnId = recordHandEdit(before, r.html, `${b.hidden ? "Hid" : "Showed"} slide ${index + 1}`,
1380
1432
  `The user ${b.hidden ? "hid" : "showed again"} slide ${index + 1} (${b.eid}). Hidden slides carry ` +
1381
- `data-visibility="hidden": they stay in the file but are skipped when presenting and in PDF exports.`,
1433
+ `data-visibility="hidden": they stay in the file but are ${skipped}.`,
1382
1434
  { manual: true, chat: false }); // the editor reloads the slide itself; undo is in the toolbar
1383
1435
  return json(res, 200, { turnId, index });
1384
1436
  }
@@ -1624,11 +1676,13 @@ const server = http.createServer(async (req, res) => {
1624
1676
  return text(res, r.status || 200, r.text);
1625
1677
  }
1626
1678
  if (p === "/api/slides") {
1627
- return text(res, 200, slideList().map((s) => `${String(s.index + 1).padStart(2)} ${s.eid}`).join("\n"));
1679
+ const list = slideList(), why = !list.length && deckProblemNow();
1680
+ if (why) return text(res, 409, why);
1681
+ return text(res, 200, list.map((s) => `${String(s.index + 1).padStart(2)} ${s.eid}`).join("\n"));
1628
1682
  }
1629
1683
  if (p === "/api/layout" || p === "/api/shot") {
1630
1684
  const slide = resolveSlide(url.searchParams.get("slide"));
1631
- if (!slide) return text(res, 404, `No slide "${url.searchParams.get("slide")}". Use \`${CLI_CMD} slides\`.`);
1685
+ if (!slide) return text(res, 404, deckProblemNow() || `No slide "${url.searchParams.get("slide")}". Use \`${CLI_CMD} slides\`.`);
1632
1686
  if (!slide.eid) runEidPass();
1633
1687
  const s2 = slide.eid ? slide : resolveSlide(String(slide.index + 1));
1634
1688
  if (p === "/api/shot") {