pindeck 2.10.0 → 2.11.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
@@ -253,7 +253,7 @@ show and commit as usual. Clones without the filter configured simply see the fi
253
253
  | Undo, redo | `⌘Z` / `⇧⌘Z` or the toolbar arrows. One history for your hand edits and Claude's changes. |
254
254
  | Pin elements | Press `P` (or the Pin button), click elements; each click gets a number. Click a marker to remove it, `Esc` to stop. |
255
255
  | Ask Claude | Write in the sidebar and refer to pins by number: "1: align with 2, make 3 smaller". The selected element is sent too, so "make this smaller" works. `Enter` sends, `Shift+Enter` adds a new line. |
256
- | Visual questions | **+** in the chat, then **Screenshot**: the current slide with its pin markers is attached when you send. |
256
+ | Visual questions | **+** in the chat, then **Screenshot**: the current slide with its pin markers is attached when you send. The chat shows it, and the screenshots Claude reads, as a small picture; click it to see it larger. |
257
257
  | Review | Each answer ends with the changed files, a **Diff** button and an **Undo** button (most recent change). |
258
258
  | Stop | **Stop** interrupts Claude; the conversation continues. **New chat** starts over. |
259
259
  | 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, and its title opens for typing. |
@@ -396,6 +396,10 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
396
396
 
397
397
  ## Versions
398
398
 
399
+ * **2.11**: screenshots in the chat. A screenshot sent with a message, and one Claude reads, shows as
400
+ a small picture that opens larger with a click (Esc or a click closes it). PinDeck keeps the 40
401
+ newest screenshots of a deck; an older one says "no longer kept". Chats from before 2.11 keep their
402
+ plain label.
399
403
  * **2.10**: decks with their own file names and shared files, decks made without reveal.js, and Claude
400
404
  working on the whole deck.
401
405
  - A deck file by name: `pindeck path/to/talk.html` (a folder still opens its `index.html`). Several
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pindeck",
3
- "version": "2.10.0",
3
+ "version": "2.11.0",
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 edits the slides.",
5
5
  "keywords": [
6
6
  "slides",
package/public/editor.css CHANGED
@@ -207,6 +207,10 @@ body.resizing-notes .stage iframe { pointer-events: none; } /* keep the drag w
207
207
  .chip .lock { color: var(--muted); }
208
208
  .chip.sel .n { background: #1a73e8; border-radius: 4px; font-size: 10px; }
209
209
  .chip .dim { color: var(--muted); font-variant-numeric: tabular-nums; }
210
+ .shot-chip { font: inherit; font-size: 12px; color: inherit; }
211
+ .shot-chip img { height: 28px; width: auto; max-width: 64px; border-radius: 3px; border: 1px solid var(--line); background: #fff; }
212
+ .msg.tool .shot-chip { margin: 0 0 0 4px; vertical-align: middle; padding: 1px; border-radius: 4px; }
213
+ .chip.gone { cursor: default; color: var(--muted); }
210
214
  .msg.edit { font-size: 12.5px; color: var(--muted); display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
211
215
  .msg.edit .files { color: var(--ink); }
212
216
  .msg.edit .undone { text-decoration: line-through; }
@@ -264,6 +268,12 @@ body.resizing-notes .stage iframe { pointer-events: none; } /* keep the drag w
264
268
  .import-dialog .import-notes .check { color: var(--ink); }
265
269
  .import-list .tag.complex { border-color: var(--orange); color: var(--orange); }
266
270
 
271
+ /* ---- Screenshot ---- */
272
+ .shot-dialog { padding: 0; border: 0; background: none; max-width: 94vw; max-height: 92vh; cursor: zoom-out; }
273
+ .shot-dialog::backdrop { background: rgba(20,22,25,.6); }
274
+ .shot-dialog img { display: block; max-width: 94vw; max-height: 92vh; object-fit: contain; background: #fff;
275
+ border-radius: 6px; box-shadow: 0 20px 60px rgba(0,0,0,.35); }
276
+
267
277
  /* ---- Diff ---- */
268
278
  .diff-dialog { width: min(1000px, 92vw); max-height: 86vh; padding: 0; border: 1px solid var(--line); border-radius: 10px;
269
279
  box-shadow: 0 20px 60px rgba(0,0,0,.25); }
package/public/editor.js CHANGED
@@ -571,17 +571,25 @@ function md(s) {
571
571
  }).join("");
572
572
  return out.replace(/<p>\u0000(\d+)\u0000<\/p>|\u0000(\d+)\u0000/g, (_, a, b) => `<pre>${blocks[a ?? b]}</pre>`);
573
573
  }
574
+ /* A screenshot in the chat: a small picture that opens larger. Chats from before 2.11 recorded no file
575
+ name (true); the server keeps only the newest 40, so an older one fails to load and says so. */
576
+ function shotChip(name, label = " screenshot") {
577
+ if (typeof name !== "string") return `<span class="chip">📷${label}</span>`;
578
+ const src = `/shots/${encodeURIComponent(name)}`;
579
+ return `<button type="button" class="chip shot-chip" data-shot="${esc(src)}" title="Open the screenshot">` +
580
+ `<img src="${esc(src)}" alt="">${label}</button>`;
581
+ }
574
582
  function entryHtml(e) {
575
583
  switch (e.kind) {
576
584
  case "user":
577
585
  return `${esc(e.text)}${e.pins?.length || e.screenshot || e.selection || e.attachments?.length ? `<div class="chips">${e.selection ?
578
586
  `<span class="chip sel" title="${esc(e.selection.eid)}"><span class="n">▢</span> S${e.selection.slide} · ${esc(e.selection.label)}</span>` : ""}${(e.pins || []).map((p) =>
579
587
  `<span class="chip" title="${esc(p.eid)}"><span class="n">${p.n}</span> S${p.slide} · ${esc(p.label || p.eid)}</span>`).join("")}${
580
- e.screenshot ? '<span class="chip">📷 screenshot</span>' : ""}${(e.attachments || []).map((a) =>
588
+ e.screenshot ? shotChip(e.screenshot) : ""}${(e.attachments || []).map((a) =>
581
589
  `<span class="chip att-chip" title="${esc(a.path)}">${a.kind === "image" ? `<img src="/deck/${esc(a.path)}" alt="">` : a.kind === "pdf" ? "📄" : "🔊"} ${esc(a.path.split("/").pop())}</span>`).join("")}</div>` : ""}`;
582
590
  case "assistant": return md(e.text);
583
591
  case "tool":
584
- return `<span><span class="tn">${esc(e.name)}</span> ${esc(e.detail || "")}${e.error ? `<span class="err">${esc(e.error)}</span>` : ""}</span>`;
592
+ return `<span><span class="tn">${esc(e.name)}</span> ${esc(e.detail || "")}${e.shot ? " " + shotChip(e.shot, "") : ""}${e.error ? `<span class="err">${esc(e.error)}</span>` : ""}</span>`;
585
593
  case "edit": {
586
594
  const t = state.turns.get(e.turnId);
587
595
  return `<span class="files ${t?.undone ? "undone" : ""}">✎ ${esc(e.label)}</span>
@@ -641,6 +649,12 @@ chatEl.addEventListener("click", async (e) => {
641
649
  $("diff-dialog").showModal();
642
650
  return;
643
651
  }
652
+ const shot = e.target.closest("[data-shot]");
653
+ if (shot) {
654
+ $("shot-img").src = shot.dataset.shot;
655
+ $("shot-dialog").showModal();
656
+ return;
657
+ }
644
658
  const u = e.target.closest("[data-undo]");
645
659
  if (u) {
646
660
  u.disabled = true;
@@ -651,6 +665,14 @@ chatEl.addEventListener("click", async (e) => {
651
665
  }
652
666
  });
653
667
  $("diff-close").onclick = () => $("diff-dialog").close();
668
+ // a picture that is gone: error events do not bubble, so they are caught on the way down
669
+ chatEl.addEventListener("error", (e) => {
670
+ const chip = e.target.closest?.(".shot-chip");
671
+ if (!chip) return;
672
+ chip.outerHTML = '<span class="chip gone" title="Only the 40 newest screenshots are kept">📷 no longer kept</span>';
673
+ }, true);
674
+ // Esc closes it (as any dialog); so does a click anywhere on it
675
+ $("shot-dialog").onclick = () => $("shot-dialog").close();
654
676
 
655
677
  function setBusy(busy) {
656
678
  state.busy = busy;
package/public/index.html CHANGED
@@ -155,6 +155,10 @@
155
155
  </form>
156
156
  </dialog>
157
157
 
158
+ <dialog id="shot-dialog" class="shot-dialog" aria-label="Screenshot">
159
+ <img id="shot-img" alt="Screenshot of the slide">
160
+ </dialog>
161
+
158
162
  <dialog id="diff-dialog" class="diff-dialog">
159
163
  <div class="dialog-head"><span id="diff-title">Changes</span><button class="small" id="diff-close">Close</button></div>
160
164
  <pre id="diff-body" class="diff"></pre>
package/server.mjs CHANGED
@@ -83,6 +83,7 @@ const deckKey = (NAMED ? `${path.basename(DECK_DIR)}-${DECK_NAME}` : path.basena
83
83
  crypto.createHash("sha1").update(NAMED ? path.join(DECK_DIR, DECK_FILE) : DECK_DIR).digest("hex").slice(0, 8);
84
84
  const STATE_DIR = path.join(HOME_DIR, "decks", deckKey);
85
85
  const SHOT_DIR = path.join(STATE_DIR, "shots");
86
+ const SHOT_NAME = /^[\w-]+\.png$/; // what takeShot writes, and all /shots/ serves
86
87
  const PDF_DIR = path.join(STATE_DIR, "attachments"); // attached PDFs: reading material, not part of the deck
87
88
  /* pindeck login: a new one-time login link from the running server, asked with the token in its server
88
89
  file (readable only by this user) */
@@ -571,7 +572,12 @@ function toolSummary(name, input = {}) {
571
572
  return !r || r.startsWith("..") ? path.basename(p) : r;
572
573
  };
573
574
  switch (name) {
574
- case "Read": return { name: "Read", detail: base(input.file_path) };
575
+ case "Read": {
576
+ // a screenshot PinDeck took: the chat shows it, by its name in the shots folder
577
+ const f = input.file_path && path.resolve(DECK_DIR, input.file_path);
578
+ const shot = f && path.dirname(f) === SHOT_DIR && SHOT_NAME.test(path.basename(f)) ? path.basename(f) : undefined;
579
+ return { name: "Read", detail: base(input.file_path), shot };
580
+ }
575
581
  case "Edit": case "MultiEdit": case "Write": return { name: "Edit", detail: base(input.file_path) };
576
582
  case "Bash": {
577
583
  // a PinDeck command only when the whole command is one: a script that ends in `cli.mjs layout`
@@ -1171,7 +1177,13 @@ const server = http.createServer(async (req, res) => {
1171
1177
  const f = safeJoin(path.join(EDITOR_DIR, "public"), p.slice(8));
1172
1178
  return f ? serveFile(res, f, null, req) : text(res, 403, "Forbidden");
1173
1179
  }
1174
- if ((p.startsWith("/deck/") || p.startsWith("/files/")) && !ok) return text(res, 403, "Not logged in");
1180
+ if ((p.startsWith("/deck/") || p.startsWith("/files/") || p.startsWith("/shots/")) && !ok) return text(res, 403, "Not logged in");
1181
+ // /shots/: the screenshots of this deck, for the chat; only PNG files directly in its shots folder
1182
+ if (p.startsWith("/shots/")) {
1183
+ const name = decodeURIComponent(p.slice(7));
1184
+ if (req.method !== "GET" || !SHOT_NAME.test(name)) return text(res, 404, "Not found");
1185
+ return serveFile(res, path.join(SHOT_DIR, name), null, req);
1186
+ }
1175
1187
  let snap;
1176
1188
  if (req.method === "GET" && (snap = p.match(/^\/(?:deck|files)\/(?:.*\/)?__pindeck-print-([\w-]+)\.html$/))) {
1177
1189
  const html = snapshots.get(snap[1]);
@@ -1254,7 +1266,8 @@ const server = http.createServer(async (req, res) => {
1254
1266
  // only files PinDeck itself stored can be attached
1255
1267
  b.attachments = (b.attachments || []).filter((a) => a && state.uploads.has(a.path) && fs.existsSync(uploadFile(a.path)));
1256
1268
  b.attachments.forEach((a) => { state.uploads.delete(a.path); if (a.waveform) state.uploads.delete(a.waveform); });
1257
- const entry = addEntry({ kind: "user", text: b.text, screenshot: !!screenshot,
1269
+ // the file's name, so the chat can show it (true in chats from before 2.11)
1270
+ const entry = addEntry({ kind: "user", text: b.text, screenshot: screenshot ? path.basename(screenshot.path) : false,
1258
1271
  attachments: b.attachments.map((a) => ({ path: a.path, kind: a.kind, name: a.name })),
1259
1272
  selection: b.selection ? { eid: b.selection.eid, label: b.selection.eid.replace(/^[^/]+\//, ""), slide: b.selection.slide.index + 1 } : null,
1260
1273
  pins: (b.pins || []).map((p) => ({ n: p.n, eid: p.eid, label: label(p), slide: p.slide.index + 1 })) });