pindeck 3.4.0 → 4.0.0-beta.1

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/lib/review.mjs ADDED
@@ -0,0 +1,200 @@
1
+ /* Review comments (after Sebastian J. Schlecht's review mode for PinDeck).
2
+
3
+ A comment is a note on a slide or one of its elements, kept for later instead of sent to the assistant now.
4
+ Comments live in <deck folder>/.pindeck/<deck>.review.json: a dot folder, so they are never deck files, never
5
+ part of an undo and never edited by the assistant. Each comment keeps what it needs to be found again after
6
+ the deck changes: the slide's id attribute (Quarto's heading anchor), title and position, and the element's
7
+ id, tag and text. A comment whose slide or element cannot be found again is flagged, never silently moved.
8
+ Next to the comments, <deck>.review.md is the review packet: the open comments, for an agent outside PinDeck. */
9
+
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import crypto from "node:crypto";
13
+ import { assignEids, parse, findSlides, walk, textOf } from "./eids.mjs";
14
+ import { writeAtomic } from "./deck-files.mjs";
15
+
16
+ const norm = (s) => String(s ?? "").replace(/\s+/g, " ").trim();
17
+ const classes = (n) => (n.attrs?.class?.value || "").split(/\s+/).filter(Boolean);
18
+ const tagOf = (n) => [n.name, ...classes(n)].join(".");
19
+
20
+ /* The deck as comments see it: per slide, its id attribute, title, position in the stacks and its elements by
21
+ id. Ids are assigned in memory (a deck PinDeck has opened has them already; a rendered Quarto deck gets them
22
+ again after each render). */
23
+ export function indexDeck(html) {
24
+ const withIds = assignEids(html).html;
25
+ const tree = parse(withIds);
26
+ const slides = findSlides(tree).map((sec, index) => {
27
+ const stacked = sec.parent?.name === "section";
28
+ const top = stacked ? sec.parent : sec;
29
+ const siblings = (n) => (n.parent?.children || []).filter((c) => c.name === sec.name);
30
+ let head = null;
31
+ walk(sec, (c) => { if (head) return false; if (/^h[1-3]$/.test(c.name)) { head = c; return false; } });
32
+ const elements = new Map();
33
+ walk(sec, (c) => {
34
+ if (c.name === "section" || (c.name === "aside" && classes(c).includes("notes"))) return false;
35
+ const eid = c.attrs?.["data-eid"]?.value;
36
+ if (!eid) return;
37
+ let fragment = false;
38
+ for (let p = c; p && p !== sec; p = p.parent) if (classes(p).includes("fragment")) fragment = true;
39
+ const src = c.name === "img" ? (c.attrs["data-src"] || c.attrs.src)?.value : null;
40
+ elements.set(eid, { eid, tag: tagOf(c), text: norm(src || textOf(withIds, c)), fragment });
41
+ });
42
+ return {
43
+ index, eid: sec.attrs["data-eid"]?.value, id: sec.attrs.id?.value || null,
44
+ title: head ? norm(textOf(withIds, head)) : "",
45
+ h: siblings(top).indexOf(top), v: stacked ? siblings(sec).indexOf(sec) : 0, elements,
46
+ };
47
+ });
48
+ return { html: withIds, slides };
49
+ }
50
+
51
+ /* A new comment from a pin placed in the editor (bridge.js onClick), or from the slide as a whole (pin null,
52
+ slide { index, eid }). */
53
+ export function commentFromPin(deck, { pin, slide: whole }, text) {
54
+ const ref = pin ? pin.slide : whole;
55
+ const slide = deck.slides[ref?.index];
56
+ if (!slide || slide.eid !== ref.eid) throw new Error("The slide has changed since the pin was placed. Pin it again.");
57
+ const onSlide = !pin || pin.eid === slide.eid;
58
+ const el = onSlide ? null : slide.elements.get(pin.eid);
59
+ if (!onSlide && !el) throw new Error("Element not found in the deck. Pin it again.");
60
+ const r = (v) => Math.round(v * 1e4) / 1e4;
61
+ const now = new Date().toISOString();
62
+ return {
63
+ id: "c-" + crypto.randomBytes(4).toString("hex"), created: now, updated: now, status: "open", text: String(text).trim(),
64
+ slide: { id: slide.id, eid: slide.eid, title: slide.title, h: slide.h, v: slide.v },
65
+ target: el ? { eid: el.eid, tag: el.tag, text: el.text, fragment: el.fragment, word: pin.word || null, cell: pin.cell || null }
66
+ : { eid: null, tag: "section", text: "", fragment: false, word: null, cell: null },
67
+ // where the marker sits: on the slide (px), and inside its element (0..1 of its box)
68
+ point: pin?.point ? { x: r(pin.point.x), y: r(pin.point.y) } : null,
69
+ at: pin?.at ? { fx: r(pin.at.fx), fy: r(pin.at.fy) } : null,
70
+ };
71
+ }
72
+
73
+ /* Where a stored comment is in the deck as it is now.
74
+ status: ok same slide, same element, same text
75
+ moved the element's text was found elsewhere on the slide (ids can be positional)
76
+ changed the element is there but its text differs: possibly already addressed
77
+ missing the slide is there, the element is not
78
+ no-slide neither the slide's id nor its title exists any more
79
+ Only "ok" and "moved" place the marker on the element; the others at the stored point on the slide (if any). */
80
+ export function locate(deck, c) {
81
+ // shortcut: repeated titles resolve to the first; add an h/v tiebreak if decks repeat titles without ids
82
+ const slide = (c.slide.id && deck.slides.find((s) => s.id === c.slide.id)) ||
83
+ (c.slide.eid && deck.slides.find((s) => s.eid === c.slide.eid && (!c.slide.title || s.title === c.slide.title))) ||
84
+ (c.slide.title && deck.slides.find((s) => s.title === c.slide.title));
85
+ if (!slide) return { status: "no-slide", slide: null, eid: null };
86
+ const t = c.target;
87
+ if (!t.eid) return { status: "ok", slide, eid: null };
88
+ const els = [...slide.elements.values()];
89
+ const same = slide.elements.get(t.eid);
90
+ if (same && same.tag === t.tag && same.text === t.text) return { status: "ok", slide, eid: same.eid };
91
+ const moved = t.text && (els.find((e) => e.tag === t.tag && e.text === t.text) || els.find((e) => e.text === t.text));
92
+ if (moved) return { status: "moved", slide, eid: moved.eid };
93
+ if (same && same.tag === t.tag) return { status: "changed", slide, eid: null, now: same.text };
94
+ return { status: "missing", slide, eid: null };
95
+ }
96
+ export const matched = (status) => status === "ok" || status === "moved";
97
+
98
+ /* ---- The comments file ------------------------------------------------------------------------ */
99
+ export function reviewFiles(deckDir, deckFile) {
100
+ const base = path.join(deckDir, ".pindeck", deckFile.replace(/\.html?$/i, ""));
101
+ return { json: base + ".review.json", packet: base + ".review.md" };
102
+ }
103
+ export function loadReview(file) {
104
+ try {
105
+ const r = JSON.parse(fs.readFileSync(file, "utf8"));
106
+ if (!r || !Array.isArray(r.comments)) throw new Error(`${file} is not a PinDeck comments file.`);
107
+ // edited by hand or by another agent: a comment without what locate() needs would crash the server later
108
+ const bad = r.comments.findIndex((c) => typeof c?.id !== "string" || typeof c.text !== "string" || !c.slide || typeof c.slide !== "object" || !c.target || typeof c.target !== "object");
109
+ if (bad >= 0) throw new Error(`comment ${bad + 1} lacks its id, text, slide or target`);
110
+ const badReplies = r.comments.findIndex((c) => c.replies != null && !(Array.isArray(c.replies) && c.replies.every((x) => typeof x?.text === "string")));
111
+ if (badReplies >= 0) throw new Error(`comment ${badReplies + 1} has replies that are not a list of { by, text }`);
112
+ return r;
113
+ } catch (e) { if (e.code === "ENOENT") return { pindeckReview: 1, deck: {}, comments: [] }; throw e; }
114
+ }
115
+ export function saveReview(file, review) {
116
+ fs.mkdirSync(path.dirname(file), { recursive: true });
117
+ writeAtomic(file, JSON.stringify(review, null, 2) + "\n"); // a crash never leaves half a file
118
+ }
119
+
120
+ /* ---- The review packet ------------------------------------------------------------------------------ */
121
+ const pandocId = (s) => norm(s.replace(/\{[^}]*\}\s*$/, "")).toLowerCase()
122
+ .replace(/[^\p{L}\p{N}\s_.-]/gu, "").replace(/^[^\p{L}]+/u, "").replace(/\s+/g, "-");
123
+
124
+ /* 1-based line of the slide's heading in a Quarto/Markdown source, or null. */
125
+ export function sourceLine(srcLines, slide) {
126
+ if (!srcLines || !slide) return null;
127
+ const i = srcLines.findIndex((l) => {
128
+ const m = /^#{1,3}\s+(.*)$/.exec(l);
129
+ if (!m) return false;
130
+ const explicit = /\{[^}]*#([\w:.-]+)/.exec(m[1]);
131
+ return explicit ? explicit[1] === slide.id : pandocId(m[1]) === slide.id || norm(m[1]) === slide.title;
132
+ });
133
+ return i < 0 ? null : i + 1;
134
+ }
135
+
136
+ const STATUS = {
137
+ ok: "ok", moved: "ok (element found again by its text)",
138
+ changed: "UNMATCHED: the element's text has changed since the comment (maybe already addressed)",
139
+ missing: "UNMATCHED: the element is no longer on this slide",
140
+ "no-slide": "UNMATCHED: the slide is no longer in the deck",
141
+ };
142
+ const clip = (s, n = 240) => (s.length > n ? s.slice(0, n - 1) + "…" : s);
143
+ const slideName = (s) => `slide ${s.h + 1}${s.v ? "." + s.v : ""} “${s.title || "(untitled)"}”`;
144
+
145
+ /* The open comments in deck order, each with where it is now and its number in the editor's list (C1, C2, …) */
146
+ export function openComments(review, deck) {
147
+ const n = new Map(review.comments.map((c, i) => [c.id, i + 1]));
148
+ const key = (x) => (x.loc.slide ? x.loc.slide.index : 1e6) * 1e5 + (x.c.point?.y ?? 0);
149
+ return review.comments.filter((c) => c.status !== "resolved").map((c) => ({ c, n: n.get(c.id), loc: locate(deck, c) }))
150
+ .sort((a, b) => key(a) - key(b));
151
+ }
152
+ const who = (by) => (by === "you" ? "Reviewer" : by === "assistant" ? "Assistant" : by ? `${by[0].toUpperCase()}${by.slice(1)}` : "Agent");
153
+ function items(list, lines) {
154
+ return list.flatMap(({ c, n, loc }) => {
155
+ const s = loc.slide, t = c.target;
156
+ const line = sourceLine(lines, s || { id: c.slide.id, title: c.slide.title });
157
+ return [`## C${n}. ${s ? slideName(s) : "was " + slideName(c.slide)}`, "",
158
+ `> ${c.text.replace(/\n/g, "\n> ")}`, "",
159
+ `- Slide: ${c.slide.id ? `\`#${c.slide.id}\`` : `\`${s?.eid || c.slide.eid}\``}${line ? `, source line ${line}` : ""} — ${STATUS[loc.status]}`,
160
+ t.eid ? `- Element: \`${t.tag}\`${matched(loc.status) ? ` (data-eid \`${loc.eid}\`)` : ""}${t.fragment ? ", a build step" : ""}: “${clip(t.text)}”`
161
+ : "- Element: the slide as a whole",
162
+ ...(t.word ? [`- Clicked word: “${t.word}”`] : []),
163
+ ...(t.cell ? [`- Table cell: row ${t.cell.row + 1}, column ${t.cell.col + 1}: “${clip(t.cell.text || "", 80)}”`] : []),
164
+ ...(loc.status === "changed" ? [`- Text now: “${clip(loc.now)}”`] : []),
165
+ ...(c.replies?.length ? ["- Thread, oldest first:", ...c.replies.map((r) => ` - ${who(r.by)}: ${r.text.replace(/\n/g, " ")}`)] : []),
166
+ `- Comment id: \`${c.id}\``, ""];
167
+ });
168
+ }
169
+
170
+ /* For an agent outside PinDeck (Claude Code in an editor, say): written next to the comments file */
171
+ export function packet(review, deck, { deckFile = "the deck", source = null, srcText = null, commentsFile = "?" } = {}) {
172
+ const open = openComments(review, deck);
173
+ const stale = open.filter((x) => !matched(x.loc.status));
174
+ const resolved = review.comments.length - open.length;
175
+ return [
176
+ `# Slide review: ${deckFile}`, "",
177
+ `- Deck: \`${deckFile}\`${source ? `, source \`${source}\`` : ""}`,
178
+ `- Comments: \`${commentsFile}\``,
179
+ `- ${open.length} open comment(s), ${stale.length} unmatched; ${resolved} resolved (not listed).`, "",
180
+ "These are reviewer comments on the slides. They are not speaker notes: do not copy them into the deck.",
181
+ source ? `Make each change in the source (\`${source}\`, its stylesheets or figure scripts), never in the rendered HTML, ` +
182
+ "which the next render overwrites. Render, then" : "Make each change, then",
183
+ "set the comment's `status` to `\"resolved\"` in the comments file. If you cannot do one, or it is unclear, leave it open",
184
+ "and add `{ \"by\": \"agent\", \"text\": \"<why>\" }` to its `replies` list, where the reviewer can answer. Read a comment's",
185
+ "thread before acting on it. UNMATCHED comments point at slides or text that changed after the comment was made:",
186
+ "check whether they still apply before acting on them.", "",
187
+ ...items(open, srcText?.split("\n")),
188
+ ].join("\n");
189
+ }
190
+
191
+ /* For PinDeck's own assistant: the open comments as one request, put in the message box for the user to send */
192
+ export function request(review, deck, { source = null, srcText = null } = {}) {
193
+ const open = openComments(review, deck);
194
+ if (!open.length) return "";
195
+ return [`Please address these ${open.length} review comment(s), one by one. They are notes for you, not slide text or speaker notes.`,
196
+ "Mark each one you have addressed as resolved with PinDeck's resolve command and its comment id. If one is unclear or no",
197
+ "longer applies, leave it open and say why with PinDeck's reply command instead of guessing. Where a comment has a",
198
+ "thread, the reviewer's latest answer is what to act on.", "",
199
+ ...items(open, source ? srcText?.split("\n") : null)].join("\n").trim();
200
+ }
package/lib/servers.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  `pindeck login` and cli.mjs.
3
3
 
4
4
  Each PinDeck process (a front, a deck server on its own, or one behind a front) keeps a file of its own in
5
- ~/.pindeck-v3/servers/<pid>.json (0600, in a 0700 folder) with its address and token. Several fronts and
5
+ ~/.pindeck-v4/servers/<pid>.json (0600, in a 0700 folder) with its address and token. Several fronts and
6
6
  servers can run side by side (another --port, another PINDECK_HOME user, a server started on its own) and
7
7
  none overwrites another's file; a file whose process is gone is removed when it is read. */
8
8
 
@@ -81,6 +81,15 @@ overlapping elements, clipped text); add --issues to print only the issues. Fix
81
81
  {{CLI}} shot <slide-eid or number>...
82
82
  only when you need a visual judgement the numbers cannot give (balance, figure content, colour)
83
83
  or the user asks; it prints the path of a PNG, which you then Read.
84
+ ## Review comments
85
+ The user can keep comments on slides (ids c-…, numbered C1, C2, … in the editor) and ask you to address them.
86
+ They are notes for you, not slide text or speaker notes. When you have addressed one, mark it resolved:
87
+ {{CLI}} resolve <comment id> <one line on what you did>
88
+ Leave a comment open when you could not do it, or it was unclear, and say why on the comment itself, where the
89
+ user can answer you:
90
+ {{CLI}} reply <comment id> <why not, or what you need to know>
91
+ A comment's thread (your replies and the user's) comes with it; read the user's latest answer before acting.
92
+
84
93
  If the user asks for a PDF, run `{{CLI}} pdf handout` (one page per slide, build steps complete) or
85
94
  `{{CLI}} pdf presentation` (one page per build step); it prints the path of the PDF.
86
95
 
package/lib/usage.mjs CHANGED
@@ -1,15 +1,16 @@
1
1
  /* pindeck --help, and the hint after a bad argument: the front (front.mjs) and a deck server (server.mjs)
2
2
  take the same arguments. */
3
- export const USAGE = `Usage: pindeck [deck-folder | deck-file.html] [--port 5180] [--provider claude|codex] [--model <name>] [--asset-root <folder>]...
3
+ export const USAGE = `Usage: pindeck [deck-folder | deck-file.html | quarto-deck.qmd] [--port 5180] [--provider claude|codex] [--model <name>] [--asset-root <folder>]...
4
4
  pindeck login [deck-folder | deck-file.html]
5
5
 
6
6
  Opens a reveal.js deck in PinDeck: a deck file such as talk.html, or a folder (default: the current
7
7
  folder), which then opens its index.html; a folder without one opens a list of its decks. A deck made
8
8
  otherwise names its slides in its <head> with <meta name="pindeck:slides" content="<CSS selector>"> and
9
- <meta name="pindeck:size" content="1440x1080">.
9
+ <meta name="pindeck:size" content="1440x1080">. A Quarto deck (talk.qmd) is rendered by PinDeck, and the
10
+ assistant edits its source.
10
11
  In the editor, the deck's name opens the other decks in the deck's folder and the asset roots (and one
11
12
  folder down), each with its own chat and undo, on the same port and login.
12
13
  login prints a new one-time login link for the running PinDeck (with a deck: one that opens that deck).
13
14
  --asset-root lets the deck use files outside its folder: a folder that contains the deck (paths such as
14
15
  ../shared/style.css then work), or one that symlinks in the deck may point to. Repeatable; also
15
- "assetRoots" in ~/.pindeck-v3/config.json.`;
16
+ "assetRoots" in ~/.pindeck-v4/config.json.`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pindeck",
3
- "version": "3.4.0",
3
+ "version": "4.0.0-beta.1",
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",
@@ -32,6 +32,9 @@
32
32
  "lib/",
33
33
  "public/",
34
34
  "README.md",
35
+ "CHANGELOG.md",
36
+ "docs/*.md",
37
+ "!docs/v4-plan.md",
35
38
  "LICENSE"
36
39
  ],
37
40
  "scripts": {
package/public/bridge.js CHANGED
@@ -42,6 +42,9 @@
42
42
  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;
43
43
  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);
44
44
  cursor:pointer; pointer-events:auto; }
45
+ .__pin.__comment { background:#1a73e8; border-radius:calc(6px*var(--pindeck-u,1)); width:auto; min-width:calc(26px*var(--pindeck-u,1));
46
+ padding:0 calc(5px*var(--pindeck-u,1)); }
47
+ .__pin.__stale { background:#8a8f98; }
45
48
  .__hover { position:absolute; z-index:999; pointer-events:none; outline:calc(2px*var(--pindeck-u,1)) dashed #ED8C01;
46
49
  outline-offset:calc(2px*var(--pindeck-u,1)); background:rgba(237,140,1,.06); }
47
50
  .__hover span { position:absolute; left:calc(-2px*var(--pindeck-u,1)); background:#ED8C01; color:#fff; white-space:nowrap;
@@ -102,19 +105,22 @@
102
105
  const byEid = (eid, root = document) => root.querySelector(`[data-eid="${CSS.escape(eid)}"]`);
103
106
 
104
107
  /* Visible text of an element, without KaTeX's hidden MathML copy. */
108
+ const MATH_TEX = 'script[type^="math/tex"]'; // MathJax 2 (Quarto) keeps the TeX here; its rendering is skipped
105
109
  function textOf(el, ownOnly) {
106
110
  if (el.tagName === "IMG") return el.getAttribute("alt") || (el.getAttribute("src") || "").split("/").pop();
107
111
  let out = "";
108
112
  const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
109
113
  acceptNode(n) {
110
114
  const p = n.parentElement;
111
- if (!p || p.closest(".katex-mathml, .__pin, .__hover, .__flash, .__sel, .__guide, .__edit-hint, .__hidden-banner, aside.notes, script, style")) return NodeFilter.FILTER_REJECT;
115
+ if (p?.matches(MATH_TEX)) return NodeFilter.FILTER_ACCEPT;
116
+ if (!p || p.closest(".katex-mathml, .MathJax, .MathJax_Display, .MathJax_Preview, .MathJax_CHTML, .MathJax_SVG, mjx-container, .__pin, .__hover, .__flash, .__sel, .__guide, .__edit-hint, .__hidden-banner, aside.notes, script, style")) return NodeFilter.FILTER_REJECT;
112
117
  if (ownOnly) { const owner = p.closest("[data-eid]"); if (owner !== el) return NodeFilter.FILTER_REJECT; }
113
118
  return NodeFilter.FILTER_ACCEPT;
114
119
  },
115
120
  });
116
121
  while (walker.nextNode()) {
117
122
  const n = walker.currentNode;
123
+ if (n.parentElement.matches(MATH_TEX)) { out += `\\(${n.nodeValue.trim()}\\) `; continue; }
118
124
  out += n.nodeValue + (n.parentElement.closest(".katex") ? "" : " "); // KaTeX splits math into one span per glyph
119
125
  }
120
126
  return out.replace(/\u200b/g, "").replace(/\s+/g, " ").trim();
@@ -239,7 +245,8 @@
239
245
  const presentHash = (index) => deck.hash(index); // the slide's address in the deck opened on its own
240
246
 
241
247
  /* ---- Pins and hover ---------------------------------------------------------------------- */
242
- let mode = "select", pins = [], editable = true; // mode: "select" (drag, resize) or "pin"
248
+ // mode: "select" (drag, resize) or "pin"; lockReason: why hand edits are off for good (a Quarto deck)
249
+ let mode = "select", pins = [], editable = true, lockReason = null;
243
250
  const tagOf = (el) => el.tagName.toLowerCase() + (el.classList.length ? "." + [...el.classList].filter((c) => !c.startsWith("__")).join(".") : "");
244
251
  function wordAt(x, y, el) {
245
252
  let node, offset;
@@ -250,7 +257,7 @@
250
257
  const r = document.caretRangeFromPoint(x, y);
251
258
  if (r) { node = r.startContainer; offset = r.startOffset; }
252
259
  }
253
- if (!node || node.nodeType !== 3 || !el.contains(node) || node.parentElement.closest(".katex-mathml")) return null;
260
+ if (!node || node.nodeType !== 3 || !el.contains(node) || node.parentElement.closest(".katex-mathml, .MathJax, mjx-container")) return null;
254
261
  const s = node.nodeValue;
255
262
  const isW = (c) => /[\p{L}\p{N}_\-.,%×]/u.test(c);
256
263
  let a = offset, b = offset;
@@ -276,6 +283,8 @@
276
283
  function onClick(e) {
277
284
  if (MODE !== "main") return;
278
285
  e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
286
+ const comment = e.target.closest?.(".__comment"); // a review comment's marker opens it, in either mode
287
+ if (comment) { host()?.openComment(comment.dataset.comment); return; }
279
288
  if (mode !== "pin") return;
280
289
  const marker = e.target.closest?.(".__pin");
281
290
  if (marker) { host()?.removePin(+marker.dataset.n); return; }
@@ -329,10 +338,11 @@
329
338
  x = b.x + p.at.fx * b.w; y = b.y + p.at.fy * b.h;
330
339
  }
331
340
  const m = document.createElement("div");
332
- m.className = "__pin";
341
+ m.className = "__pin" + (p.comment ? " __comment" : "") + (p.stale ? " __stale" : "");
333
342
  m.dataset.n = p.n;
343
+ if (p.comment) m.dataset.comment = p.comment;
334
344
  m.textContent = p.n;
335
- m.title = "Click to remove pin " + p.n;
345
+ m.title = p.title || "Click to remove pin " + p.n;
336
346
  Object.assign(m.style, { left: x + "px", top: y + "px" });
337
347
  sec.appendChild(m);
338
348
  }
@@ -487,7 +497,7 @@
487
497
  }
488
498
  if (!sel || sel.el !== t.el) select(t.el, t.sec);
489
499
  }
490
- if (!editable) { if (handle) host()?.toast("Claude is working. Moving and resizing resume when it has finished."); return; }
500
+ if (!editable) { if (handle) host()?.toast(lockReason || "Claude is working. Moving and resizing resume when it has finished."); return; }
491
501
  const el = sel.el, cs = getComputedStyle(el);
492
502
  const { s } = frame(sel.sec);
493
503
  const [tx, ty] = translateOf(el);
@@ -576,7 +586,7 @@
576
586
  }
577
587
  function nudge(dx, dy) {
578
588
  if (!sel) return;
579
- if (!editable) return host()?.toast("Claude is working. Moving and resizing resume when it has finished.");
589
+ if (!editable) return host()?.toast(lockReason || "Claude is working. Moving and resizing resume when it has finished.");
580
590
  const el = sel.el;
581
591
  const [tx, ty] = translateOf(el);
582
592
  el.style.translate = fmtTranslate(tx + dx, ty + dy);
@@ -641,7 +651,7 @@
641
651
  }
642
652
 
643
653
  async function startEdit(t, x, y, opts = {}) {
644
- if (!editable) return host()?.toast("Claude is working. Editing resumes when it has finished.");
654
+ if (!editable) return host()?.toast(lockReason || "Claude is working. Editing resumes when it has finished.");
645
655
  if (editing) await finishEdit(true);
646
656
  const src = opts.src ?? await host()?.textSource(t.eid, t.path);
647
657
  if (src == null || !t.el.isConnected) return;
@@ -1087,7 +1097,7 @@
1087
1097
  isEditing: () => !!editing,
1088
1098
  deselect() { select(null, null); },
1089
1099
  hasSelection: () => !!sel,
1090
- setEditable(on) { editable = !!on; if (!editable && editing) finishEdit(true); if (sel) drawSel(); },
1100
+ setEditable(on, why = null) { editable = !!on; lockReason = why; if (!editable && editing) finishEdit(true); if (sel) drawSel(); },
1091
1101
  renderPins,
1092
1102
  async measure(index) {
1093
1103
  goto(index);
package/public/editor.css CHANGED
@@ -173,7 +173,7 @@ code { font: 12.5px ui-monospace, SFMono-Regular, Menlo, monospace; background:
173
173
  /* the splitter: the panel's top edge, dragged up or down like the notes divider in PowerPoint or Keynote */
174
174
  .notes-split { position: absolute; left: 0; right: 0; top: -4px; height: 8px; z-index: 2; cursor: row-resize; touch-action: none; }
175
175
  .notes-split::after { content: ""; position: absolute; left: 0; right: 0; top: 3px; height: 2px; background: transparent; transition: background .12s; }
176
- .notes-split:hover::after, .notes-split:focus-visible::after, body.resizing-notes .notes-split::after { background: var(--orange); }
176
+ .notes-split:hover::after, .notes-split:focus-visible::after, .notes-split.dragging::after { background: var(--orange); }
177
177
  .notes-split:focus-visible { outline: none; }
178
178
  .notes-panel.collapsed .notes-split { display: none; }
179
179
  body.resizing-notes { cursor: row-resize; user-select: none; }
@@ -244,16 +244,47 @@ body.resizing-notes .stage iframe { pointer-events: none; } /* keep the drag w
244
244
  .spinner { width: 12px; height: 12px; border: 2px solid var(--line); border-top-color: var(--orange); border-radius: 50%;
245
245
  animation: spin .8s linear infinite; }
246
246
  @keyframes spin { to { transform: rotate(360deg); } }
247
- .composer { border-top: 1px solid var(--line); padding: 10px 12px 12px; display: flex; flex-direction: column; gap: 8px; }
247
+ .composer { position: relative; border-top: 1px solid var(--line); padding: 10px 12px 12px; display: flex; flex-direction: column; gap: 8px; }
248
248
  .pin-tray:empty { display: none; }
249
249
  .pin-tray { max-height: 96px; overflow-y: auto; }
250
250
  #input { width: 100%; resize: none; font: inherit; border: 1px solid var(--line); border-radius: var(--radius);
251
- padding: 8px 10px; min-height: 64px; max-height: 220px; outline: none; }
251
+ padding: 8px 10px; outline: none; }
252
252
  #input:focus { border-color: var(--orange); box-shadow: 0 0 0 3px rgba(237,140,1,.15); }
253
253
  .composer-row { display: flex; align-items: center; gap: 8px; }
254
254
  .composer-row .spacer { flex: 1; }
255
255
  .check { display: inline-flex; align-items: center; gap: 5px; font-size: 12.5px; color: var(--muted); cursor: pointer; }
256
256
 
257
+ /* ---- A Quarto deck: rendered from its source, so no hand edits ---- */
258
+ body.quarto #add-slide-btn, body.quarto .slide-list .eye, body.quarto .hand-only { display: none; }
259
+ body:not(.quarto) .quarto-only { display: none; }
260
+ body.quarto #notes:empty::before { content: "Speaker notes come from ::: {.notes} in the Quarto source."; }
261
+
262
+ /* ---- Review comments ---- */
263
+ .count:empty { display: none; }
264
+ .count { background: #1a73e8; color: #fff; border-radius: 8px; padding: 0 5px; font-size: 11px; font-weight: 600; }
265
+ .comments-panel { border-bottom: 1px solid var(--line); max-height: 45%; overflow-y: auto; padding: 8px 12px 10px; background: var(--bg); }
266
+ .comments-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
267
+ .comments-head .spacer { flex: 1; }
268
+ .comments-empty, .comments-file { font-size: 12.5px; margin: 4px 0; color: var(--muted); }
269
+ .comment-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
270
+ .cmt { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 6px 8px; cursor: pointer; font-size: 13px; }
271
+ .cmt:hover, .cmt.flash { border-color: #1a73e8; }
272
+ .cmt .top { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 12px; }
273
+ .cmt .n { background: #1a73e8; color: #fff; border-radius: 4px; padding: 0 5px; font-weight: 700; font-size: 11px; }
274
+ .cmt.stale .n { background: #8a8f98; }
275
+ .cmt .top .spacer { flex: 1; }
276
+ .cmt .text { margin: 4px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
277
+ .cmt .edit { display: block; width: 100%; box-sizing: border-box; margin: 4px 0 0; font: inherit; resize: vertical; }
278
+ .cmt .reply { margin: 4px 0 0 10px; padding-left: 6px; border-left: 2px solid var(--line); white-space: pre-wrap; overflow-wrap: anywhere; }
279
+ .cmt .reply.mine { border-left-color: #1a73e8; }
280
+ .cmt.resolved .reply { text-decoration: none; }
281
+ .cmt .edit-actions { display: flex; align-items: center; gap: 6px; margin-top: 4px; font-size: 12px; }
282
+ .cmt .flag { margin: 4px 0 0; color: var(--danger); font-size: 12px; }
283
+ .cmt.resolved { opacity: .6; }
284
+ .cmt.resolved .text { text-decoration: line-through; }
285
+ .cmt .done { margin: 4px 0 0; color: var(--ok); font-size: 12px; }
286
+ .cmt.resolved .done { opacity: 1; }
287
+
257
288
  /* ---- Delete confirmation ---- */
258
289
  .delete-dialog { width: min(460px, 92vw); padding: 0; border: 1px solid var(--line); border-radius: 12px;
259
290
  box-shadow: 0 20px 60px rgba(0,0,0,.25); }