pindeck 2.8.0 → 2.8.2

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,5 +1,7 @@
1
1
  # PinDeck
2
2
 
3
+ > **macOS only.** Windows and Linux are not supported.
4
+
3
5
  **Pin it. Say it. Done.** PinDeck is a local editor for HTML slide decks (reveal.js): the slides on
4
6
  the left, the slide in the middle, and a chat with Claude Code on the right. You click elements to
5
7
  pin them, write what to change, and Claude edits the HTML and CSS. The slide updates in place.
@@ -26,6 +28,10 @@ checked the layout; the next request, still being typed, refers to the new pins
26
28
 
27
29
  ## Quick start
28
30
 
31
+ Requires macOS. The npm package declares `"os": ["darwin"]`, so normal npm installation
32
+ rejects other operating systems. Overriding that check does not make those platforms supported. Running the server from a cloned
33
+ repository on another OS shows a local support notice instead of opening the deck or starting Claude.
34
+
29
35
  ```bash
30
36
  npx pindeck path/to/deck # from npm
31
37
 
@@ -249,6 +255,16 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
249
255
 
250
256
  ## Versions
251
257
 
258
+ * **2.8.2**: declares macOS-only support in npm metadata and the README to prevent accidental
259
+ installation on unsupported operating systems. Source launches on other platforms show a support
260
+ notice without opening the deck.
261
+
262
+ * **2.8.1**: fixes from a review. Speaker notes that fail to load leave the field empty and locked, instead
263
+ of showing the previous slide's notes and saving your edits over them. A save that fails because the
264
+ server cannot be reached is reported, and the edits after it (drags, text, bullets, notes) are still
265
+ saved instead of being dropped without a word. Stop no longer ends a turn started after it. Slides in
266
+ vertical stacks keep their order and ids, and a slide moved to the end goes after a final stack.
267
+ A first automated test suite (`npm test`).
252
268
  * **2.8**: a code review: safer, sturdier, and a PowerPoint import that keeps more. The server answers
253
269
  only to `127.0.0.1` and `localhost`, so another website cannot read the editor's token (DNS
254
270
  rebinding). A missing `claude` command ends the turn with a message instead of leaving the chat
package/lib/eids.mjs CHANGED
@@ -145,7 +145,7 @@ function find(node, pred) {
145
145
  return hit;
146
146
  }
147
147
 
148
- /* Slides are the <section>s inside the element with class "slides" (Reveal.js). */
148
+ /* Slides are leaf <section>s inside .slides; vertical-stack containers are not slides. */
149
149
  export function findSlides(tree) {
150
150
  const slidesEl = find(tree, (c) => classesOf(c).includes("slides"));
151
151
  if (!slidesEl) return [];
@@ -153,8 +153,8 @@ export function findSlides(tree) {
153
153
  const visit = (node) => {
154
154
  for (const c of node.children) {
155
155
  if (c.name !== "section") continue;
156
- out.push(c);
157
- visit(c); // vertical stacks
156
+ if (c.children.some((child) => child.name === "section")) visit(c);
157
+ else out.push(c);
158
158
  }
159
159
  };
160
160
  visit(slidesEl);
@@ -218,10 +218,11 @@ export function assignEids(html) {
218
218
 
219
219
  // Keep every existing, non-duplicate id first so new names never collide with them.
220
220
  const keep = new Set();
221
- sections.forEach((s) => walk({ children: [s] }, (c) => {
221
+ // Reserve container ids too, including ids assigned to stacks by older versions.
222
+ walk(tree, (c) => {
222
223
  const v = c.attrs?.["data-eid"]?.value;
223
224
  if (v && !used.has(v)) { used.add(v); keep.add(c); c.eid = v; }
224
- }));
225
+ });
225
226
 
226
227
  const slides = [];
227
228
  sections.forEach((section, i) => {
@@ -518,7 +519,9 @@ export function moveSlide(html, eid, beforeEid) {
518
519
  const at = slideBlock(cut, byEid(rest, beforeEid)).start;
519
520
  out = cut.slice(0, at) + text + `\n\n${indent}` + cut.slice(at);
520
521
  } else {
521
- const last = rest.filter((s) => s.parent.name !== "section").at(-1);
522
+ // 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"))
524
+ .children.filter((c) => c.name === "section").at(-1);
522
525
  const at = elementEnd(cut, last);
523
526
  out = cut.slice(0, at) + `\n\n${indent}` + text + cut.slice(at);
524
527
  }
@@ -0,0 +1,34 @@
1
+ import http from 'node:http';
2
+
3
+ export function createPlatformNoticeServer(platform) {
4
+ const windows = platform === 'win32';
5
+ const heading = windows ? 'Windows support is on the roadmap' : 'PinDeck currently requires macOS';
6
+ const explanation = windows
7
+ ? 'PinDeck is currently available for macOS only. Windows support is planned for a future version; there is no release date yet.'
8
+ : 'This version of PinDeck supports macOS only. Linux and other operating systems are not supported yet.';
9
+ const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PinDeck · macOS required</title><style>
10
+ :root{font-family:system-ui,sans-serif;color:#243348;background:#f4f6f8;color-scheme:light}*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px}main{width:min(100%,620px);padding:40px;background:white;border:1px solid #dde3ea;border-radius:18px;box-shadow:0 12px 40px #20334d0a}.brand{font-weight:700;color:#c36516;margin:0 0 32px}h1{font-size:clamp(26px,5vw,36px);line-height:1.2;margin:0 0 20px}p{line-height:1.65}a{color:#945012}footer{margin-top:28px;font-size:14px;color:#596577}
11
+ </style></head><body><main><p class="brand">PinDeck</p><h1>${heading}</h1><p>${explanation}</p><p>Your deck has not been opened or changed, and no AI backend has been started.</p><p>You can use PinDeck on a Mac or follow updates on <a href="https://github.com/ehabets/PinDeck">GitHub</a>.</p><footer>Close this tab and press Ctrl+C in the terminal to stop this notice.</footer></main></body></html>`;
12
+ return http.createServer((req, res) => {
13
+ if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405, { Allow: 'GET, HEAD' }); return res.end(); }
14
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store',
15
+ 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'" });
16
+ res.end(req.method === 'HEAD' ? undefined : html);
17
+ });
18
+ }
19
+
20
+ // Keep startup here until the notice is closed: no deck/state initialization runs on unsupported OSes.
21
+ export async function showPlatformNotice(port, platform = process.platform) {
22
+ const server = createPlatformNoticeServer(platform);
23
+ await new Promise((resolve, reject) => {
24
+ const stop = () => server.close();
25
+ server.once('error', reject);
26
+ server.once('close', () => {
27
+ process.off('SIGINT', stop); process.off('SIGTERM', stop); resolve();
28
+ });
29
+ server.listen(port, '127.0.0.1', () => {
30
+ process.once('SIGINT', stop); process.once('SIGTERM', stop);
31
+ console.log(`PinDeck requires macOS. Open http://127.0.0.1:${server.address().port}/ for details. Press Ctrl+C to stop.`);
32
+ });
33
+ });
34
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pindeck",
3
- "version": "2.8.0",
4
- "description": "PinDeck: a local editor for HTML slide decks. Pin elements, describe the change, and Claude Code edits the slides.",
3
+ "version": "2.8.2",
4
+ "description": "PinDeck: a local macOS editor for HTML slide decks. Pin elements, describe the change, and Claude Code edits the slides.",
5
5
  "keywords": [
6
6
  "slides",
7
7
  "presentation",
@@ -32,8 +32,10 @@
32
32
  "LICENSE"
33
33
  ],
34
34
  "scripts": {
35
- "start": "node server.mjs"
35
+ "start": "node server.mjs",
36
+ "test": "node --test"
36
37
  },
38
+ "os": ["darwin"],
37
39
  "engines": {
38
40
  "node": ">=20"
39
41
  }
package/public/editor.js CHANGED
@@ -100,28 +100,28 @@ window.__editorHost = {
100
100
  return d.html;
101
101
  },
102
102
  commitText(edit) {
103
- editChain = editChain.then(async () => {
103
+ queueEdit(async () => {
104
104
  const res = await api("text", edit);
105
105
  if (!res.ok) toast((await res.json().catch(() => ({}))).error || "Could not save the text.");
106
106
  loadMain(); // re-render: math, footer numbers, and the file as saved
107
- });
107
+ }, "Could not save the text.");
108
108
  },
109
109
  commitSplit(op, done) {
110
- editChain = editChain.then(async () => {
110
+ queueEdit(async () => {
111
111
  const res = await api("split", op);
112
112
  const d = await res.json().catch(() => ({}));
113
113
  if (!res.ok) { toast(d.error || "Could not add the bullet."); loadMain(); return; }
114
114
  done?.(d.eid);
115
- });
115
+ }, "Could not add the bullet.");
116
116
  },
117
117
  commitRemove(eid) {
118
- editChain = editChain.then(async () => {
118
+ queueEdit(async () => {
119
119
  const res = await api("remove", { eid });
120
120
  if (!res.ok) toast((await res.json().catch(() => ({}))).error || "Could not remove the bullet.");
121
121
  loadMain();
122
- });
122
+ }, "Could not remove the bullet.");
123
123
  },
124
- refresh() { editChain = editChain.then(() => loadMain()); },
124
+ refresh() { queueEdit(() => loadMain(), "Could not reload the slide."); },
125
125
  editingChanged(on) { state.editing = on; },
126
126
  requestDelete(eid) { requestDelete(eid); },
127
127
  dropFiles(files, at) { attachFiles(files, at); },
@@ -131,15 +131,20 @@ window.__editorHost = {
131
131
 
132
132
  /* ---- Hand edits (drag, resize, nudge) ---------------------------------------------------------- */
133
133
  let editChain = Promise.resolve(), nudgeAcc = null, nudgeTimer = null;
134
+ // Saves run one after another. An edit that throws (offline, server gone) is reported and the queue
135
+ // carries on, so one failure never silently skips the edits after it.
136
+ function queueEdit(fn, failMsg = "Could not save the change.") {
137
+ editChain = editChain.catch(() => {}).then(fn).catch(() => { toast(failMsg); });
138
+ return editChain;
139
+ }
134
140
  function postEdit(op, label) {
135
- editChain = editChain.then(async () => {
141
+ return queueEdit(async () => {
136
142
  const res = await api("edit", { ops: [op], label });
137
143
  if (!res.ok) {
138
144
  toast((await res.json().catch(() => ({}))).error || "Could not save the change.");
139
145
  loadMain(); // show the file as it is
140
146
  }
141
147
  });
142
- return editChain;
143
148
  }
144
149
  function flushNudge() {
145
150
  clearTimeout(nudgeTimer);
@@ -285,29 +290,47 @@ async function loadNotes() {
285
290
  if (!eid || document.activeElement === notesBox) return;
286
291
  const seq = ++notesSeq;
287
292
  await editChain.catch(() => {}); // a save still on its way would otherwise be read back as the old text
288
- if (seq !== notesSeq) return;
293
+ if (seq !== notesSeq || notesDirty()) return;
289
294
  const res = await api(`notes?${new URLSearchParams({ eid })}`).catch(() => null);
290
295
  const d = res?.ok ? await res.json().catch(() => null) : null;
291
- if (seq !== notesSeq || document.activeElement === notesBox) return; // a newer load, or the user started typing
292
- notes = { eid, text: d?.text || "", html: !!d?.html };
296
+ if (seq !== notesSeq || document.activeElement === notesBox || notesDirty()) return;
297
+ if (!d) { // detach from the previous slide, so its notes are not shown, or saved over, as this one's
298
+ notes = { eid: null, text: "", html: false };
299
+ notesBox.value = "";
300
+ notesBox.disabled = true;
301
+ return notesStatus("Could not load");
302
+ }
303
+ notes = { eid, text: d.text || "", html: !!d.html };
293
304
  notesBox.value = notes.text;
305
+ notesBox.disabled = false;
294
306
  notesStatus();
295
307
  }
296
308
  function saveNotes() {
297
- if (!notesDirty()) return;
309
+ if (!notesDirty()) return Promise.resolve(true);
298
310
  const edit = { eid: notes.eid, text: notesBox.value, html: notes.html };
299
311
  flushNudge();
300
- editChain = editChain.then(async () => {
312
+ const run = async () => {
301
313
  notesStatus("Saving…");
302
- const res = await api("notes", edit);
303
- const d = await res.json().catch(() => ({}));
304
- if (!res.ok) { notesStatus(); return toast(d.error || "Could not save the notes."); } // the text stays, to save again
314
+ let res, d;
315
+ try {
316
+ res = await api("notes", edit);
317
+ d = await res.json().catch(() => ({}));
318
+ } catch {} // a network failure must not break the shared edit queue
319
+ if (!res?.ok) {
320
+ notesStatus("Not saved");
321
+ toast(d?.error || "Could not save the notes.");
322
+ return false;
323
+ }
305
324
  if (notes.eid === edit.eid) notes.text = edit.text;
306
325
  notesStatus("Saved");
307
326
  setTimeout(() => { if ($("notes-status").textContent === "Saved") notesStatus(); }, 1500);
308
- });
327
+ return true;
328
+ };
329
+ return queueEdit(run, "Could not save the notes.");
309
330
  }
310
- notesBox.addEventListener("blur", () => { saveNotes(); loadNotes(); }); // then show the slide we are on now
331
+ notesBox.addEventListener("blur", async () => {
332
+ if (await saveNotes()) loadNotes(); // retain a failed draft and its original slide id for retry
333
+ });
311
334
  notesBox.addEventListener("keydown", (e) => {
312
335
  if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); notesBox.blur(); }
313
336
  else if (e.key === "Escape") { e.preventDefault(); notesBox.value = notes.text; notesBox.blur(); }
package/server.mjs CHANGED
@@ -41,6 +41,11 @@ const argv = process.argv.slice(2);
41
41
  if (argv.includes("--help") || argv.includes("-h")) { console.log(USAGE); process.exit(0); }
42
42
  const opt = (name, dflt) => { const i = argv.indexOf(name); return i >= 0 ? argv.splice(i, 2)[1] : dflt; };
43
43
  const PORT = +opt("--port", process.env.PORT || 5180);
44
+ if (process.platform !== "darwin") {
45
+ const { showPlatformNotice } = await import("./lib/platform-notice.mjs");
46
+ await showPlatformNotice(PORT);
47
+ process.exit(0);
48
+ }
44
49
  const MODEL = opt("--model", process.env.CLAUDE_MODEL || "");
45
50
  const DECK_DIR = path.resolve(argv[0] || process.cwd());
46
51
  const DECK_FILE = "index.html";
@@ -964,8 +969,11 @@ const server = http.createServer(async (req, res) => {
964
969
  }
965
970
  if (p === "/api/stop" && req.method === "POST") {
966
971
  if (state.busy && session.running) {
972
+ const interruptedTurn = state.turn;
967
973
  session.interrupt();
968
- setTimeout(() => { if (state.busy) { session.stop(); } }, 5000); // force if it does not answer
974
+ setTimeout(() => {
975
+ if (state.busy && state.turn === interruptedTurn) session.stop();
976
+ }, 5000); // force only if the interrupted turn does not answer
969
977
  }
970
978
  return json(res, 200, {});
971
979
  }