pindeck 3.3.0 → 3.4.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # PinDeck
2
2
 
3
- > **macOS and Linux · Version 3** (`3.3.0`). On Linux, PinDeck runs as a headless server that you use
3
+ > **macOS and Linux · Version 3** (`3.4.0`). On Linux, PinDeck runs as a headless server that you use
4
4
  > from your Mac's browser through an SSH tunnel (see [Running on a Linux server](#running-on-a-linux-server)).
5
5
  > Windows is not supported. Coming from PinDeck 2: version 3 keeps its settings and chats in
6
6
  > `~/.pindeck-v3`, apart from 2.x (see [Models, providers and configuration](#models-providers-and-configuration)).
@@ -328,7 +328,7 @@ ssh -N -T -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCo
328
328
 
329
329
  Open the login link PinDeck printed on the server in the Mac's browser. The local port may differ from
330
330
  the server's: with `-L 127.0.0.1:8080:127.0.0.1:5180`, change 5180 in the link to 8080. PinDeck accepts any
331
- port, as long as the address is `127.0.0.1` or `localhost`. If the tunnel drops, a message you have already sent keeps running on the
331
+ port, as long as the address is `127.0.0.1`: `localhost` is where decks are presented, and nobody logs in there. If the tunnel drops, a message you have already sent keeps running on the
332
332
  server. Restart the tunnel and the editor reconnects and shows the files as they are then; it never sends a
333
333
  message twice. If PinDeck restarts, the editor asks you to log in again: run `node front.mjs login
334
334
  path/to/deck` on the server for a new link.
@@ -391,7 +391,7 @@ show and commit as usual. Clones without the filter configured simply see the fi
391
391
  | Stop | **Stop** interrupts the assistant; the conversation continues. **New chat** starts a new conversation; your slides, Undo and Redo stay as they are. |
392
392
  | New slide | **+ New slide** above the slide list: a **content slide** (title and bullets), a **section divider**, or a **duplicate** of the current slide. It is added after the current slide, modelled on the deck's own slides; a new content slide or divider opens its title for typing. |
393
393
  | Import from PowerPoint | **+ New slide** → **Import from PowerPoint…**, then choose a `.pptx` file and tick the slides you want (hidden PowerPoint slides start unticked). They are added after the current slide in this deck's own layouts, and the assistant can improve the complex ones; see [PowerPoint import](#powerpoint-import). `⌘Z` removes them again, with their figures. |
394
- | Speaker notes | The **Notes** field under the slide shows the current slide's notes (the speaker view, `S`, shows them when presenting). Type to change them; they are saved when you click elsewhere or press `⌘Enter`, and `Esc` puts back what was there. A slide without notes gets them; emptying the field removes them. Notes with formatting other than line breaks are edited as HTML. Drag the line above the notes to give them more or less room (double-click it for the default size); collapse them with their header. |
394
+ | Speaker notes | The **Notes** field under the slide shows the current slide's notes (the speaker view, `S`, shows them when presenting). Type to change them; they are saved when you click elsewhere or press `⌘Enter`, and `Esc` puts back what was there. A slide without notes gets them; emptying the field removes them. Notes show their formatting: `⌘B`, `⌘I` and `⌘U` format the selection, `Enter` starts a new line, and pasted text comes in plain. Notes with markup the field cannot show safely (other tags, styles, scripts) are edited as HTML. Drag the line above the notes to give them more or less room (double-click it for the default size); collapse them with their header. |
395
395
  | Move a slide | Drag it up or down in the slide list; an orange line shows where it will go. It moves with its notes and the comment above it, and `⌘Z` puts it back. Slides inside vertical stacks cannot be moved yet. |
396
396
  | Hide a slide | The eye on a slide in the list, or `H`. A hidden slide stays in the deck and in PinDeck (greyed out, with a banner) but is skipped when presenting and in PDFs; **Export ▾** can include hidden slides. Show it the same way. |
397
397
  | Model and effort | Choose a discovered Claude or OpenAI model in the chat header, then use the separate reasoning-effort selector. Provider changes offer an optional handover; same-provider changes continue the conversation. The choice is saved in `~/.pindeck-v3/config.json`. |
@@ -457,10 +457,13 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
457
457
  only six connections to one port; if a deck server stops, the front opens the deck again and the tab
458
458
  catches up. Each PinDeck process notes its address in `~/.pindeck-v3/servers/` for `pindeck login` and
459
459
  `cli.mjs`, and a deck server holds its deck's `open.json`, so a deck opens in one PinDeck only.
460
- * **Deck pages are sandboxed.** The deck page (in the editor's frames, and in Present) runs with a sandbox
461
- policy in an origin of its own, at a per-run address that serves the deck's files and nothing else. A
462
- script in a deck, a template, or written by the assistant cannot reach the login cookie, the API, the
463
- chat or other decks. The editor and its bridge in the slide frame talk by `postMessage`. Headless Chrome
460
+ * **Deck pages are sandboxed.** The deck page in the editor's frames runs with a sandbox policy in an
461
+ origin of its own, at a per-run address that serves the deck's files and nothing else. A script in a
462
+ deck, a template, or written by the assistant cannot reach the login cookie, the API, the chat or other
463
+ decks. The editor and its bridge in the slide frame talk by `postMessage`. Present opens the same
464
+ address on `localhost` without the sandbox, so reveal.js's speaker view (`S`) works: browsers treat
465
+ `localhost` and `127.0.0.1` as different sites, PinDeck takes no login and no cookie there, and the tab is
466
+ opened without a handle on the editor. Headless Chrome
464
467
  (screenshots, layout, PDFs) does not log in at all; the page it asks for is served without the sandbox,
465
468
  which its screenshot and print modes cannot handle, only with a key made for that one run.
466
469
  * **Element ids.** On start, the server gives every slide and every visible element a readable
@@ -551,13 +554,22 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
551
554
  * The undo history lives on the server and starts fresh when PinDeck restarts, or when a deck closes after
552
555
  30 minutes without an open editor.
553
556
  * One writer at a time: PinDeck is not made for editing the same deck from two editors or tools at once.
554
- * A deck's own scripts run sandboxed in the editor and in Present: they cannot use `localStorage`, cookies
555
- or the editor page. A deck that needs them for its slides to work should catch the error.
557
+ * A deck's own scripts run sandboxed in the editor: they cannot use `localStorage`, cookies or the editor
558
+ page. A deck that needs them for its slides to work should catch the error. In Present they can, on
559
+ `localhost`, where every deck of one PinDeck shares the same `localStorage`.
556
560
  * Linux: pictures of PDF pages and PowerPoint slides, and waveforms of non-WAV audio, need
557
561
  Poppler, LibreOffice and ffmpeg on the server (see [Running on a Linux server](#running-on-a-linux-server)).
558
562
 
559
563
  ## Versions
560
564
 
565
+ * **3.4.0**: formatted speaker notes, and the speaker view works.
566
+ - The Notes field shows the notes' formatting: `⌘B`, `⌘I` and `⌘U` format the selection, `Enter` starts
567
+ a new line, and pasted text comes in plain. Notes with markup the field cannot show safely are still
568
+ edited as HTML, and nothing in them is lost on save.
569
+ - Present opens the deck on localhost, so reveal.js's speaker view (`S`) shows the notes and the next slide.
570
+ - Claude runs PinDeck's commands by this server's path when the deck's `CLAUDE.md` names another
571
+ (such as `node ../pindeck/cli.mjs`), and a chat that used an old path is told the right one.
572
+
561
573
  * **3.3.0**: fixes from a code audit.
562
574
  - After a PowerPoint import, refining the slides no longer deletes pictures that the deck still uses
563
575
  (when the turn was stopped, failed or only changed `theme.css`); Undo brings removed pictures back.
package/front.mjs CHANGED
@@ -150,7 +150,8 @@ setInterval(async () => {
150
150
  streamed back (event streams, media ranges and large uploads included) */
151
151
  function proxy(req, res, d, rest, anonymous = false) {
152
152
  d.last = Date.now();
153
- const headers = { ...req.headers, host: new URL(d.url).host, ...(anonymous ? {} : { "x-editor-token": d.token }) };
153
+ // the deck's pages keep the browser's host: on localhost (Present) the deck server leaves out the sandbox
154
+ const headers = { ...req.headers, host: anonymous ? req.headers.host : new URL(d.url).host, ...(anonymous ? {} : { "x-editor-token": d.token }) };
154
155
  if (anonymous) delete headers["x-editor-token"];
155
156
  delete headers.cookie;
156
157
  delete headers.origin; // checked here against the front's address; the deck server checks its own
@@ -287,7 +288,7 @@ const server = http.createServer(async (req, res) => {
287
288
  try {
288
289
  const url = new URL(req.url, ORIGIN);
289
290
  const p = url.pathname;
290
- if (req.method === "GET" && p === "/login") return login(res, url);
291
+ if (req.method === "GET" && p === "/login") return login(res, url, host);
291
292
  // the editor's own scripts and styles hold nothing secret; the deck page loads bridge.js from here
292
293
  if (req.method === "GET" && p.startsWith("/editor/")) return serveEditorFile(res, p.slice(8));
293
294
  /* A deck's pages and files under /d/<deck>/v/<token>/: asked for by the deck itself, which runs sandboxed
@@ -119,3 +119,16 @@ export function cliCommand(rel) {
119
119
  if (!/["$`\\!]/.test(rel)) return `node "${rel}"`;
120
120
  return `node '${rel.replaceAll("'", `'\\''`)}'`;
121
121
  }
122
+
123
+ /* A deck's CLAUDE.md names the CLI by where PinDeck sat when it was written (`node ../cli.mjs`,
124
+ `node ../pindeck/cli.mjs`). Claude may run only this server's command, so every one outside the deck (../) becomes it. */
125
+ export function useCliCommand(text, cmd) {
126
+ return text.replace(/node (?:\.\.\/)+(?:[\w.@-]+\/)*cli\.mjs\b/g, () => cmd);
127
+ }
128
+
129
+ /* Why a refused command was refused, when it is PinDeck's CLI under another path (one Claude saw earlier
130
+ in a resumed conversation, from where PinDeck sat then): the same command as it must be run. Else null. */
131
+ export function cliRefusal(command, cmd) {
132
+ const fixed = useCliCommand(String(command ?? ""), cmd);
133
+ return fixed !== String(command ?? "") ? `Refused: PinDeck's command is \`${cmd}\` now. Run \`${fixed}\` instead.` : null;
134
+ }
package/lib/claude.mjs CHANGED
@@ -33,7 +33,7 @@ export class ClaudeSession {
33
33
  onMessage: ev => {
34
34
  if (this.transport !== transport) return;
35
35
  if (ev.type === 'control_request') { transport.write({ type: 'control_response', response: { subtype: 'success', request_id: ev.request_id,
36
- response: { behavior: 'deny', message: 'This tool is outside PinDeck permissions.' } } }); return; }
36
+ response: { behavior: 'deny', message: this.denyMessage?.(ev.request) || 'This tool is outside PinDeck permissions.' } } }); return; }
37
37
  if (ev.session_id) this.sessionId = ev.session_id;
38
38
  this.onEvent?.(ev);
39
39
  },
package/lib/eids.mjs CHANGED
@@ -122,7 +122,7 @@ export function parse(html) {
122
122
  const classesOf = (node) => (node.attrs.class?.value || "").split(/\s+/).filter(Boolean);
123
123
 
124
124
  const ENTITIES = { nbsp: " ", amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
125
- function decode(s) { // one pass, so "&amp;lt;" stays "&lt;"
125
+ export function decode(s) { // one pass, so "&amp;lt;" stays "&lt;"
126
126
  return s.replace(/&(?:(nbsp|amp|lt|gt|quot|apos)|#(\d+)|#x([0-9a-f]+));/gi, (m, name, d, x) =>
127
127
  name ? ENTITIES[name.toLowerCase()] : String.fromCodePoint(d ? +d : parseInt(x, 16)));
128
128
  }
package/lib/login.mjs CHANGED
@@ -11,6 +11,10 @@ import { execFile } from "node:child_process";
11
11
  /* Pages opened as 127.0.0.1 or localhost only, on any port: an SSH tunnel may use another local port.
12
12
  Another site's name pointed at 127.0.0.1 (DNS rebinding) must not read the editor page, its token or the deck. */
13
13
  export const goodHost = (h) => /^(127\.0\.0\.1|localhost)(:\d{1,5})?$/.test(h);
14
+ /* localhost is where decks are presented, without the sandbox (reveal.js's speaker view needs a real origin):
15
+ no login there, and no cookie counts, so a deck's script on it gets nothing it could not already read.
16
+ Browsers treat localhost and 127.0.0.1 as different sites, so the login cookie of 127.0.0.1 never goes there. */
17
+ export const presentHost = (h) => /^localhost(:\d{1,5})?$/.test(String(h || "").toLowerCase());
14
18
 
15
19
  export const LOGIN_PAGE = `<!doctype html><meta charset="utf-8"><title>PinDeck</title>
16
20
  <body style="font:16px system-ui,sans-serif;max-width:36em;margin:4em auto;padding:0 1em">
@@ -40,10 +44,15 @@ export function createLogin({ token, port, origin }) {
40
44
  const cookieName = () => `pindeck-${port()}`;
41
45
  const sameSecret = (a) => typeof a === "string" && a.length === token.length &&
42
46
  crypto.timingSafeEqual(Buffer.from(a), Buffer.from(token));
43
- const authed = (req) => sameSecret(req.headers["x-editor-token"]) || sameSecret(cookie(req, cookieName()));
47
+ const authed = (req) => sameSecret(req.headers["x-editor-token"]) ||
48
+ (!presentHost(req.headers.host) && sameSecret(cookie(req, cookieName())));
44
49
  /* A one-time code: set the cookie, then go on with a same-origin navigation (a redirect from a cross-site
45
50
  start, such as a file:// page or a link in a terminal, would not carry a SameSite=Strict cookie). */
46
- function login(res, url) {
51
+ function login(res, url, host) {
52
+ if (presentHost(host)) { // logins happen on 127.0.0.1 only: the same link there, its code still unused
53
+ res.writeHead(302, { location: `http://127.0.0.1${host.slice(9)}${url.pathname}${url.search}`, "cache-control": "no-store" });
54
+ return res.end();
55
+ }
47
56
  const code = url.searchParams.get("code") || "";
48
57
  const exp = loginCodes.get(code);
49
58
  loginCodes.delete(code);
@@ -64,8 +64,8 @@ user asks for: facts, numbers and wording for slides, or a figure.
64
64
  - Never put the PDF itself on a slide or link it from the deck.
65
65
 
66
66
  ## Commands
67
- The only shell commands you can run here are the PinDeck commands below ({{CLI}} ...), each on
68
- its own: no scripts, heredocs, temporary files, loops, variables or `&&` chains, and no python, sips,
67
+ The only shell commands you can run here are the PinDeck commands below ({{CLI}} ...), with exactly
68
+ this path to cli.mjs even if another one appears earlier in the conversation, each on its own: no scripts, heredocs, temporary files, loops, variables or `&&` chains, and no python, sips,
69
69
  convert or ls. Such commands are refused (or need an approval nobody can give) and are not run. A
70
70
  refused command did nothing: do not retry it as it is, do it another way.
71
71
  - To change files, use Edit, even for large changes: several Edit calls in a row are fine.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pindeck",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
4
4
  "description": "PinDeck: an editor for HTML slide decks on macOS, and on Linux 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/editor.css CHANGED
@@ -183,12 +183,15 @@ body.resizing-notes .stage iframe { pointer-events: none; } /* keep the drag w
183
183
  .notes-head:hover { background: var(--bg); border: 0; }
184
184
  .notes-head .caret { display: inline-block; transition: transform .15s; }
185
185
  .notes-panel.collapsed .caret { transform: rotate(-90deg); }
186
- .notes-panel.collapsed textarea { display: none; }
186
+ .notes-panel.collapsed .notes-box { display: none; }
187
187
  .notes-status { margin-left: auto; font-weight: 400; text-transform: none; letter-spacing: 0; }
188
- .notes-panel textarea { height: 96px; resize: none; border: 0; border-top: 1px solid var(--line);
188
+ .notes-box { height: 96px; overflow-y: auto; box-sizing: border-box; border: 0; border-top: 1px solid var(--line);
189
189
  padding: 8px 14px; font: inherit; font-size: 13.5px; line-height: 1.45; color: var(--ink); background: var(--panel); outline: none; }
190
- .notes-panel textarea:focus { background: #fffcf6; }
191
- .notes-panel textarea[readonly] { color: var(--muted); }
190
+ .notes-box:focus { background: #fffcf6; }
191
+ .notes-box:empty::before { content: attr(data-placeholder); color: var(--muted); }
192
+ .notes-box[contenteditable="false"] { color: var(--muted); }
193
+ .notes-box[contenteditable="plaintext-only"] { white-space: pre-wrap; font-family: ui-monospace, Menlo, monospace; font-size: 12.5px; }
194
+ .notes-box p, .notes-box ul, .notes-box ol { margin: 0 0 .5em; }
192
195
 
193
196
  /* ---- Chat ---- */
194
197
  .chat-pane { display: flex; flex-direction: column; min-height: 0; border-left: 1px solid var(--line); background: var(--panel); }
package/public/editor.js CHANGED
@@ -365,16 +365,44 @@ async function moveSlideTo(from, to) { // to: insertion index in the current l
365
365
  }
366
366
 
367
367
  /* ---- Speaker notes: the field under the slide -------------------------------------------------------
368
- Shows the current slide's <aside class="notes"> and saves it as a hand edit when the field loses focus
369
- or on ⌘Enter; Esc puts back what the file says. Notes with markup other than line breaks are edited
370
- as their HTML ("HTML" in the header). Typing is never overwritten by a reload. */
368
+ Shows the current slide's <aside class="notes"> as formatted text (⌘B ⌘I ⌘U; Enter is a line break) and
369
+ saves its HTML as a hand edit when the field loses focus or on ⌘Enter; Esc puts back what the file says.
370
+ Notes with markup the field cannot show safely (other tags or attributes) are edited as their HTML
371
+ ("HTML" in the header). Typing is never overwritten by a reload. */
372
+ const NOTE_TAGS = new Set(["P", "BR", "B", "STRONG", "I", "EM", "U", "S", "SUB", "SUP", "CODE", "SPAN", "SMALL", "MARK",
373
+ "UL", "OL", "LI", "A", "BLOCKQUOTE", "DIV"]);
374
+ const safeNoteAttr = (el, a) => a.name === "class" || (a.name === "href" && el.tagName === "A" && /^(https?:|mailto:|#)/i.test(a.value.trim()));
375
+ /* The notes' HTML when the field can show it unchanged (only harmless tags and attributes), else null. Parsed inert. */
376
+ function richNotes(html) {
377
+ const body = new DOMParser().parseFromString("<body>" + html, "text/html").body; // <body>: a leading <style> or <script> stays in it, to be seen
378
+ for (const el of body.querySelectorAll("*"))
379
+ if (!NOTE_TAGS.has(el.tagName) || [...el.attributes].some((a) => !safeNoteAttr(el, a))) return null;
380
+ return body.innerHTML;
381
+ }
371
382
  const notesBox = $("notes");
372
- let notes = { eid: null, text: "", html: false }; // the notes as the file has them
383
+ let notes = { eid: null, text: "", html: false }; // the field's content as loaded; html: edited as raw HTML
373
384
  let notesSeq = 0;
374
- const notesDirty = () => !!notes.eid && notesBox.value.trim() !== notes.text.trim();
385
+ const notesValue = () => notes.html ? notesBox.textContent : notesBox.innerHTML;
386
+ const notesDirty = () => !!notes.eid && notesValue().trim() !== notes.text.trim();
387
+ /* What is saved: the raw HTML as typed, or the field's HTML without the browser's own styling of edits */
388
+ function notesHtml() {
389
+ if (notes.html) return notesBox.textContent;
390
+ if (!notesBox.textContent.trim()) return "";
391
+ const c = notesBox.cloneNode(true);
392
+ c.querySelectorAll("[style]").forEach((el) => el.removeAttribute("style"));
393
+ return c.innerHTML.replace(/(\s*<br>)+\s*$/, "");
394
+ }
395
+ /* Shows the notes `d` ({ text, html } from the server, or null: none) of slide `eid` */
396
+ function showNotes(eid, d) {
397
+ const rich = !d ? "" : d.html ? richNotes(d.text || "") : (d.text || "").split("\n").map(esc).join("<br>");
398
+ notes = { eid, text: "", html: rich == null };
399
+ notesBox.contentEditable = !d ? "false" : notes.html ? "plaintext-only" : "true";
400
+ if (notes.html) notesBox.textContent = d.text; else notesBox.innerHTML = rich;
401
+ notes.text = notesValue();
402
+ }
375
403
  function notesStatus(msg) {
376
404
  $("notes-status").textContent = msg ?? (notes.html ? "HTML" : "");
377
- $("notes-status").title = msg ? "" : notes.html ? "These notes contain formatting, so you edit them as HTML" : "";
405
+ $("notes-status").title = msg ? "" : notes.html ? "These notes contain markup the field cannot show, so you edit them as HTML" : "";
378
406
  }
379
407
  async function loadNotes() {
380
408
  const eid = state.slides[state.current]?.eid;
@@ -386,19 +414,15 @@ async function loadNotes() {
386
414
  const d = res?.ok ? await res.json().catch(() => null) : null;
387
415
  if (seq !== notesSeq || document.activeElement === notesBox || notesDirty()) return;
388
416
  if (!d) { // detach from the previous slide, so its notes are not shown, or saved over, as this one's
389
- notes = { eid: null, text: "", html: false };
390
- notesBox.value = "";
391
- notesBox.disabled = true;
417
+ showNotes(null, null);
392
418
  return notesStatus("Could not load");
393
419
  }
394
- notes = { eid, text: d.text || "", html: !!d.html };
395
- notesBox.value = notes.text;
396
- notesBox.disabled = false;
420
+ showNotes(eid, d);
397
421
  notesStatus();
398
422
  }
399
423
  function saveNotes() {
400
424
  if (!notesDirty()) return Promise.resolve(true);
401
- const edit = { eid: notes.eid, text: notesBox.value, html: notes.html };
425
+ const edit = { eid: notes.eid, text: notesHtml(), html: true }, shown = notesValue();
402
426
  flushNudge();
403
427
  const run = async () => {
404
428
  notesStatus("Saving…");
@@ -412,7 +436,7 @@ function saveNotes() {
412
436
  toast(d?.error || "Could not save the notes.");
413
437
  return false;
414
438
  }
415
- if (notes.eid === edit.eid) notes.text = edit.text;
439
+ if (notes.eid === edit.eid) notes.text = shown;
416
440
  notesStatus("Saved");
417
441
  setTimeout(() => { if ($("notes-status").textContent === "Saved") notesStatus(); }, 1500);
418
442
  return true;
@@ -424,12 +448,23 @@ notesBox.addEventListener("blur", async () => {
424
448
  });
425
449
  notesBox.addEventListener("keydown", (e) => {
426
450
  if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); notesBox.blur(); }
427
- else if (e.key === "Escape") { e.preventDefault(); notesBox.value = notes.text; notesBox.blur(); }
451
+ else if (e.key === "Escape") {
452
+ e.preventDefault();
453
+ if (notes.html) notesBox.textContent = notes.text; else notesBox.innerHTML = notes.text;
454
+ notesBox.blur();
455
+ }
456
+ // a <br>, as the notes have them, not a new <div>
457
+ else if (e.key === "Enter" && !notes.html) { e.preventDefault(); document.execCommand("insertLineBreak"); }
458
+ });
459
+ notesBox.addEventListener("paste", (e) => { // pasted text comes in plain: no styles or markup from elsewhere
460
+ e.preventDefault();
461
+ document.execCommand("insertText", false, e.clipboardData.getData("text/plain"));
428
462
  });
463
+ notesBox.addEventListener("input", () => { if (notesBox.innerHTML === "<br>") notesBox.innerHTML = ""; }); // placeholder again
429
464
  // closing the tab while typing: send what is there (sendBeacon cannot set headers, hence the token in the URL)
430
465
  addEventListener("pagehide", () => {
431
466
  if (notesDirty() && !state.busy)
432
- navigator.sendBeacon(BASE + "api/notes", new Blob([JSON.stringify({ eid: notes.eid, text: notesBox.value, html: notes.html })]));
467
+ navigator.sendBeacon(BASE + "api/notes", new Blob([JSON.stringify({ eid: notes.eid, text: notesHtml(), html: true })]));
433
468
  });
434
469
  function setNotesOpen(open) {
435
470
  $("notes-panel").classList.toggle("collapsed", !open);
@@ -580,8 +615,13 @@ $("export-menu").addEventListener("click", async (e) => {
580
615
  }
581
616
  });
582
617
 
618
+ /* Present on localhost, at the deck's view path: no login there, so no sandbox, and reveal.js's speaker view (S)
619
+ works. noopener: the deck's scripts get no handle on this editor. */
583
620
  $("present").onclick = () => {
584
- window.open(`${BASE}deck/${state.slides[state.current]?.hash || ""}`, "_blank");
621
+ const view = state.status?.view || "";
622
+ const u = new URL(`${BASE}${view}deck/${state.slides[state.current]?.hash || ""}`, location.href);
623
+ if (view) u.hostname = "localhost";
624
+ window.open(u, "_blank", "noopener");
585
625
  };
586
626
 
587
627
  /* ---- Pins --------------------------------------------------------------------------------------- */
@@ -852,7 +892,7 @@ function handleKey(k, mod = {}) {
852
892
  else if (k === "ArrowRight" || k === "ArrowDown" || k === "PageDown" || k === " ") goto(state.current + 1);
853
893
  }
854
894
  document.addEventListener("keydown", (e) => {
855
- if (e.target.closest?.("input, textarea, dialog")) return;
895
+ if (e.target.closest?.("input, textarea, dialog, [contenteditable]")) return;
856
896
  // a menu and the button that opens it keep Enter, Space and the arrow keys (a menu opens, an item is chosen):
857
897
  // no slide change, no jump to the chat. Other buttons do not: after a click on ‹ › the focus stays there
858
898
  // while the slide is clicked (the slide frame keeps it), and Enter must still edit the selected text.
package/public/index.html CHANGED
@@ -71,8 +71,9 @@
71
71
  title="Drag to resize the notes (double-click: default size)" aria-label="Resize the speaker notes"></div>
72
72
  <button id="notes-toggle" class="notes-head" aria-expanded="true" aria-controls="notes" title="Show or hide the speaker notes">
73
73
  <span class="caret" aria-hidden="true">▾</span> Notes <span id="notes-status" class="notes-status"></span></button>
74
- <textarea id="notes" spellcheck="true" aria-label="Speaker notes of this slide"
75
- placeholder="Speaker notes for this slide, shown in the speaker view (S) when presenting. Saved when you click elsewhere or press ⌘Enter; Esc undoes your typing."></textarea>
74
+ <div id="notes" class="notes-box" contenteditable="false" role="textbox" aria-multiline="true" spellcheck="true"
75
+ aria-label="Speaker notes of this slide"
76
+ data-placeholder="Speaker notes for this slide, shown in the speaker view (S) when presenting. ⌘B bold, ⌘I italic, ⌘U underline. Saved when you click elsewhere or press ⌘Enter; Esc undoes your typing."></div>
76
77
  </section>
77
78
  </main>
78
79
 
package/server.mjs CHANGED
@@ -23,7 +23,7 @@ import path from "node:path";
23
23
  import crypto from "node:crypto";
24
24
  import { spawn, execFile } from "node:child_process";
25
25
  import { fileURLToPath, pathToFileURL } from "node:url";
26
- import { assignEids, parse, findSlides, slideSelector, deckSize, setStyles, getInner, setInner, insertAfter, removeElement, deleteTarget, fileRefs, insertSlide, moveSlide, getNotes, setNotes, setAttr, elementEnd } from "./lib/eids.mjs";
26
+ import { assignEids, parse, findSlides, slideSelector, deckSize, setStyles, getInner, setInner, insertAfter, removeElement, deleteTarget, fileRefs, insertSlide, moveSlide, getNotes, setNotes, setAttr, elementEnd, decode } from "./lib/eids.mjs";
27
27
  import { unifiedDiff } from "./lib/diff.mjs";
28
28
  import { ClaudeSession } from "./lib/claude.mjs";
29
29
  import { CodexSession } from "./lib/codex.mjs";
@@ -34,7 +34,7 @@ import { printLayout, printPdf, chromeReason, ROOT_ARGS, PDF_HEAD_TAG, PDF_PAGE_
34
34
  import { readPptx } from "./lib/pptx.mjs";
35
35
  import { exportPdf as pptxToPdf, renderJobs, renderAvailable, pdfInfo } from "./lib/pptx-render.mjs";
36
36
  import { buildImport, planSlides } from "./lib/pptx-import.mjs";
37
- import { isSupportedPlatform, detectCapabilities, publicCapabilities, capabilityPrompt, parsePort, cliCommand, findExecutable } from "./lib/capabilities.mjs";
37
+ import { isSupportedPlatform, detectCapabilities, publicCapabilities, capabilityPrompt, parsePort, cliCommand, useCliCommand, cliRefusal, findExecutable } from "./lib/capabilities.mjs";
38
38
  import { unsupportedMessage } from "./lib/frameworks.mjs";
39
39
  import { readInstructions } from "./lib/instructions.mjs";
40
40
  import { writeAtomic, sameContent, revision, scanFiles, scanChanges } from "./lib/deck-files.mjs";
@@ -42,7 +42,7 @@ import { deckTarget, homeDir, homeWarning } from "./lib/deck-target.mjs";
42
42
  import { listDecks, linkProblem } from "./lib/deck-list.mjs";
43
43
  import { assetRoots } from "./lib/asset-roots.mjs";
44
44
  import { registerServer, printLogin, alive } from "./lib/servers.mjs";
45
- import { goodHost, LOGIN_PAGE, createLogin, openOnEnter } from "./lib/login.mjs";
45
+ import { goodHost, presentHost, LOGIN_PAGE, createLogin, openOnEnter } from "./lib/login.mjs";
46
46
  import { USAGE } from "./lib/usage.mjs";
47
47
 
48
48
  /* ---- Config ------------------------------------------------------------------ */
@@ -543,7 +543,7 @@ let session = null, starting = null;
543
543
  const status = () => ({ busy: state.busy, switching: state.switching, preparing: !!state.preparing, model: state.model, sessionId: state.sessionId, settings: { ...settings },
544
544
  running: !!session?.running, provider: settings.provider, models: catalog, deck: DECK_DIR, deckFile: DECK_FILE,
545
545
  chrome: CAPS.screenshot.available, slideSelector: deckNow.selector, deckProblem: deckNow.problem,
546
- capabilities: publicCapabilities(CAPS), rev: deckRev(),
546
+ capabilities: publicCapabilities(CAPS), rev: deckRev(), view: VIEW.slice(1) + "/",
547
547
  undo: changeLabel(liveTurns().at(-1)), undoId: liveTurns().at(-1)?.id ?? null, redo: changeLabel(state.redo.at(-1)) });
548
548
  /* Pushed to the editors on every change. The model lists (both providers' catalogs) go along only when they
549
549
  changed since the last push: the editor keeps the last ones it got. */
@@ -625,12 +625,12 @@ function othersWorkedSince(start) {
625
625
  MCP servers have no place in a slide editor). That also skips CLAUDE.md files, so the deck's own go
626
626
  into the system prompt instead, read afresh at each session start: CLAUDE.md and AGENTS.md (the name
627
627
  other coding agents read), with their @path imports (lib/instructions.mjs). Parent folders' files are
628
- not loaded; a deck imports shared rules explicitly. Decks copied from the example call the CLI as
629
- `node ../cli.mjs`, which is right only next to PinDeck: that becomes this server's command. */
628
+ not loaded; a deck imports shared rules explicitly. The CLI path written there becomes this server's
629
+ command (useCliCommand). */
630
630
  function deckInstructions() {
631
631
  return ["CLAUDE.md", "AGENTS.md"].map((name) => {
632
632
  const s = readInstructions(path.join(DECK_DIR, name));
633
- return s ? `\n\n## The deck's ${name}\n${s.replace(/node \.\.\/cli\.mjs\b/g, () => CLI_CMD)}\n` : "";
633
+ return s ? `\n\n## The deck's ${name}\n${useCliCommand(s, CLI_CMD)}\n` : "";
634
634
  }).join("");
635
635
  }
636
636
  // a function: ORIGIN is known only once the port is bound
@@ -668,9 +668,11 @@ function makeSession(choice) {
668
668
  explicit), PinDeck's own commands, never the locked paths. Deny rules win over allow rules. */
669
669
  const agent = choice.provider === "codex" ? new CodexSession({ ...options, settings: choice,
670
670
  instructions: codexInstructions(), tools: agentTools }) : new ClaudeSession({ ...options,
671
+ // a tool the rules do not allow comes to PinDeck (--permission-prompt-tool stdio), always refused: with the right command if it was the CLI
672
+ denyMessage: (r) => r?.tool_name === "Bash" ? cliRefusal(r.input?.command, CLI_CMD) : null,
671
673
  args: () => ["--append-system-prompt", systemPrompt + slidesInstructions() + deckInstructions(),
672
674
  "--setting-sources", "", "--strict-mcp-config", "--safe-mode", "--disable-slash-commands", // no settings files, no MCP servers or connectors
673
- "--permission-mode", "default", "--tools", "Read,Glob,Grep,Edit,Write,Bash",
675
+ "--permission-mode", "default", "--permission-prompt-tool", "stdio", "--tools", "Read,Glob,Grep,Edit,Write,Bash",
674
676
  "--allowedTools", ...readRules(), "Edit(./**)", "Write(./**)",
675
677
  ...["layout", "shot", "slides", "pdf", "pdf-figure"].map(command => `Bash(${CLI_CMD} ${command}:*)`),
676
678
  "--disallowedTools", ...lockedPaths().flatMap((f) => [`Edit(./${f})`, `Write(./${f})`]),
@@ -1467,9 +1469,10 @@ let ready = false;
1467
1469
  const NEEDS_DECK = /^\/api\/(message|slides|layout|shot|pdf|pdf-figure|edit|text|split|remove|notes|delete|delete-info|source|slide-hidden|move-slide|add-slide|import-apply|export)$/;
1468
1470
  const CLI_ROUTES = /^\/api\/(slides|layout|shot|pdf|pdf-figure)$/; // answered as text, for cli.mjs
1469
1471
  /* Scriptable files (HTML, SVG) from the deck get the sandbox, wherever they are asked for. Under /v/ they are
1470
- read by documents of that sandboxed origin, so fonts and module scripts need CORS; the path is the secret. */
1472
+ read by documents of that sandboxed origin, so fonts and module scripts need CORS; the path is the secret.
1473
+ On localhost (Present) there is no sandbox: no login works there (see presentHost). */
1471
1474
  const SCRIPTABLE = /\.(html|svg|xml)$/i;
1472
- const deckHeaders = (f, viewing) => ({ ...(SCRIPTABLE.test(f) ? { "content-security-policy": SANDBOX } : {}),
1475
+ const deckHeaders = (f, viewing, open) => ({ ...(SCRIPTABLE.test(f) && !open ? { "content-security-policy": SANDBOX } : {}),
1473
1476
  ...(viewing ? { "access-control-allow-origin": "*" } : {}) });
1474
1477
  const server = http.createServer(async (req, res) => {
1475
1478
  if (!ready) return text(res, 503, "PinDeck is starting. Reload in a moment.");
@@ -1478,7 +1481,7 @@ const server = http.createServer(async (req, res) => {
1478
1481
  try {
1479
1482
  const url = new URL(req.url, ORIGIN);
1480
1483
  let p = url.pathname;
1481
- if (req.method === "GET" && p === "/login") return login(res, url);
1484
+ if (req.method === "GET" && p === "/login") return login(res, url, host);
1482
1485
  // /v/<token>/deck/... and /v/<token>/files/...: the deck's pages and files, read-only, nothing else
1483
1486
  let viewing = false;
1484
1487
  if (p.startsWith("/v/")) {
@@ -1526,7 +1529,7 @@ const server = http.createServer(async (req, res) => {
1526
1529
  const f = deckPath;
1527
1530
  if (!viewing && /\.html$/i.test(f)) return toView(p);
1528
1531
  const mode = url.searchParams.get("editor");
1529
- if (!mode || !/\.html$/i.test(f)) return serveFile(res, f, null, req, deckHeaders(f, viewing));
1532
+ if (!mode || !/\.html$/i.test(f)) return serveFile(res, f, null, req, deckHeaders(f, viewing, presentHost(host)));
1530
1533
  return serveFile(res, f, (s) => {
1531
1534
  // headless runs must not stream media, or Chrome never finishes the page
1532
1535
  if (mode === "shot" || mode === "dump" || mode === "pdf") s = s.replace(/\bpreload="(auto|metadata)"/g, 'preload="none"');
@@ -1537,7 +1540,7 @@ const server = http.createServer(async (req, res) => {
1537
1540
  }
1538
1541
  return s.replace(/<\/body>/i, BRIDGE_TAG);
1539
1542
  }, req, ["shot", "dump", "pdf"].includes(mode) && headlessKeys.delete(url.searchParams.get("headless"))
1540
- ? deckHeaders(".css", viewing) : deckHeaders(f, viewing)); // headless Chrome's page, once (see chromeStart)
1543
+ ? deckHeaders(".css", viewing) : deckHeaders(f, viewing, presentHost(host))); // headless Chrome's page, once (see chromeStart)
1541
1544
  }
1542
1545
  if (viewing) return text(res, 404, "Not found");
1543
1546
 
@@ -1872,8 +1875,9 @@ const server = http.createServer(async (req, res) => {
1872
1875
  if (!r.changed) return json(res, 200, { unchanged: true });
1873
1876
  const n = findSlides(parse(before[DECK_FILE])).findIndex((s) => s.attrs["data-eid"]?.value === b.eid) + 1;
1874
1877
  const text = String(b.text || "").trim();
1878
+ const said = b.html ? decode(text.replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]*>/g, "")).trim() : text; // Claude reads the words, not the tags
1875
1879
  const turnId = recordHandEdit(before, r.html, text ? `Edited the notes of slide ${n}` : `Removed the notes of slide ${n}`,
1876
- text ? `The user edited the speaker notes of slide ${n} (${b.eid}) by hand. They now read: "${text.slice(0, 300)}".`
1880
+ text ? `The user edited the speaker notes of slide ${n} (${b.eid}) by hand. They now read: "${said.slice(0, 300)}".`
1877
1881
  : `The user removed the speaker notes of slide ${n} (${b.eid}).`); // manual: the slide itself does not change
1878
1882
  return json(res, 200, { turnId });
1879
1883
  }