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/public/editor.js CHANGED
@@ -122,9 +122,9 @@ const editorHost = {
122
122
  state.current = Math.min(state.current, state.slides.length - 1);
123
123
  if (inf.current !== state.current) bridge.goto(state.current);
124
124
  bridge.setMode(state.mode);
125
- bridge.setEditable(!state.busy);
125
+ applyEditable(bridge);
126
126
  refreshPinsFromSlides();
127
- bridge.renderPins(state.pins);
127
+ bridge.renderPins(allPins());
128
128
  if (state.sel) bridge.selectEid(state.sel.eid);
129
129
  if (state.pendingTitleEdit) { state.pendingTitleEdit = false; setTimeout(() => bridge.editTitle(), 50); }
130
130
  renderSlides();
@@ -144,6 +144,7 @@ const editorHost = {
144
144
  state.pins.push({ ...pin, n });
145
145
  pinsChanged();
146
146
  },
147
+ openComment(id) { openComment(id); },
147
148
  removePin(n) {
148
149
  state.pins = state.pins.filter((p) => p.n !== n);
149
150
  pinsChanged();
@@ -396,7 +397,7 @@ function notesHtml() {
396
397
  function showNotes(eid, d) {
397
398
  const rich = !d ? "" : d.html ? richNotes(d.text || "") : (d.text || "").split("\n").map(esc).join("<br>");
398
399
  notes = { eid, text: "", html: rich == null };
399
- notesBox.contentEditable = !d ? "false" : notes.html ? "plaintext-only" : "true";
400
+ notesBox.contentEditable = !d || state.status?.source ? "false" : notes.html ? "plaintext-only" : "true";
400
401
  if (notes.html) notesBox.textContent = d.text; else notesBox.innerHTML = rich;
401
402
  notes.text = notesValue();
402
403
  }
@@ -481,31 +482,37 @@ function setNotesHeight(h, save = true) {
481
482
  $("notes-split").setAttribute("aria-valuenow", String(h));
482
483
  if (save) try { localStorage.setItem("editor.notesHeight", String(h)); } catch {}
483
484
  }
484
- $("notes-split").addEventListener("pointerdown", (e) => {
485
- if (e.button !== 0) return;
486
- e.preventDefault();
487
- const split = $("notes-split"), y0 = e.clientY, h0 = notesBox.offsetHeight;
488
- split.setPointerCapture(e.pointerId);
489
- document.body.classList.add("resizing-notes");
490
- const move = (ev) => setNotesHeight(h0 - (ev.clientY - y0));
491
- const up = () => {
492
- document.body.classList.remove("resizing-notes");
493
- split.removeEventListener("pointermove", move);
494
- split.removeEventListener("pointerup", up);
495
- split.removeEventListener("pointercancel", up);
496
- };
497
- split.addEventListener("pointermove", move);
498
- split.addEventListener("pointerup", up);
499
- split.addEventListener("pointercancel", up);
500
- });
501
- $("notes-split").addEventListener("dblclick", () => setNotesHeight(NOTES_H));
502
- $("notes-split").addEventListener("keydown", (e) => {
503
- const step = e.shiftKey ? 64 : 16;
504
- if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
505
- e.preventDefault();
506
- e.stopPropagation(); // not a slide change
507
- setNotesHeight(notesBox.offsetHeight + (e.key === "ArrowUp" ? step : -step));
508
- });
485
+ // drag a splitter (or ↑ ↓ on it) to resize the box below it; double-click for the default size
486
+ function splitter(split, getH, setH, defH) {
487
+ split.addEventListener("pointerdown", (e) => {
488
+ if (e.button !== 0) return;
489
+ e.preventDefault();
490
+ const y0 = e.clientY, h0 = getH();
491
+ split.setPointerCapture(e.pointerId);
492
+ document.body.classList.add("resizing-notes");
493
+ split.classList.add("dragging");
494
+ const move = (ev) => setH(h0 - (ev.clientY - y0));
495
+ const up = () => {
496
+ document.body.classList.remove("resizing-notes");
497
+ split.classList.remove("dragging");
498
+ split.removeEventListener("pointermove", move);
499
+ split.removeEventListener("pointerup", up);
500
+ split.removeEventListener("pointercancel", up);
501
+ };
502
+ split.addEventListener("pointermove", move);
503
+ split.addEventListener("pointerup", up);
504
+ split.addEventListener("pointercancel", up);
505
+ });
506
+ split.addEventListener("dblclick", () => setH(defH));
507
+ split.addEventListener("keydown", (e) => {
508
+ const step = e.shiftKey ? 64 : 16;
509
+ if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
510
+ e.preventDefault();
511
+ e.stopPropagation(); // not a slide change
512
+ setH(getH() + (e.key === "ArrowUp" ? step : -step));
513
+ });
514
+ }
515
+ splitter($("notes-split"), () => notesBox.offsetHeight, setNotesHeight, NOTES_H);
509
516
  setNotesHeight((() => { try { return +localStorage.getItem("editor.notesHeight") || NOTES_H; } catch { return NOTES_H; } })(), false);
510
517
  setNotesOpen((() => { try { return localStorage.getItem("editor.notes") !== "closed"; } catch { return true; } })());
511
518
 
@@ -640,7 +647,7 @@ function refreshPinsFromSlides() {
640
647
  });
641
648
  }
642
649
  function pinsChanged() {
643
- main?.bridge.renderPins(state.pins);
650
+ main?.bridge.renderPins(allPins());
644
651
  renderPinTray();
645
652
  renderSlides();
646
653
  }
@@ -682,6 +689,146 @@ $("pin-tray").addEventListener("mouseover", (e) => {
682
689
  });
683
690
  $("clear-pins").onclick = () => { state.pins = []; pinsChanged(); };
684
691
 
692
+ /* ---- Review comments (lib/review.mjs): notes on the slides, kept for later ---------------------------
693
+ Shown in the panel under the chat header and as blue markers on the slides (grey: the element or slide
694
+ they were made on is no longer there as it was). They never go into the deck. */
695
+ const MATCHED = new Set(["ok", "moved"]);
696
+ const FLAG = {
697
+ changed: "Unmatched: the element's text has changed since (already addressed?)",
698
+ missing: "Unmatched: the element is no longer on this slide",
699
+ "no-slide": "Unmatched: the slide is no longer in the deck",
700
+ };
701
+ state.comments = [];
702
+ let showResolved = false;
703
+ let editing = null; // { id, text, reply }: the comment being edited or replied to, kept across re-renders (comments refresh while you type)
704
+ const WHO = { you: "You", assistant: "Assistant", "command line": "Command line" };
705
+ try { showResolved = localStorage.getItem("editor.comments.resolved") === "1"; } catch {}
706
+ $("comments-resolved").checked = showResolved;
707
+ const commentWhere = (c) => `S${c.loc.index != null ? c.loc.index + 1 : "?"} · ${c.target.eid ? c.target.tag.split(".")[0] : "slide"}`;
708
+ function commentPins() {
709
+ return state.comments.filter((c) => c.status !== "resolved" && c.loc.slideEid).map((c) => ({
710
+ comment: c.id, n: "C" + c.n, stale: !MATCHED.has(c.loc.status),
711
+ slide: { eid: c.loc.slideEid, index: c.loc.index }, eid: c.loc.eid || c.loc.slideEid,
712
+ point: c.point || { x: 30, y: 30 }, at: c.at || { fx: 0, fy: 0 },
713
+ title: `C${c.n}: ${c.text.slice(0, 160)}${FLAG[c.loc.status] ? "\n" + FLAG[c.loc.status] : ""}`,
714
+ }));
715
+ }
716
+ const allPins = () => [...state.pins, ...commentPins()];
717
+ function applyComments(v) {
718
+ state.comments = v.comments || [];
719
+ const open = state.comments.filter((c) => c.status !== "resolved").length;
720
+ $("comments-count").textContent = open || "";
721
+ $("comments-btn").title = v.problem || "Review comments: notes on the slides, kept for later";
722
+ $("comments-file").textContent = v.file ? `Saved in ${v.file}; the open ones, for an agent outside PinDeck, in ${v.packet}.` : v.problem || "";
723
+ renderComments();
724
+ main?.bridge.renderPins(allPins());
725
+ }
726
+ function renderComments() {
727
+ const list = state.comments.filter((c) => showResolved || c.status !== "resolved");
728
+ $("comments-empty").hidden = list.length > 0;
729
+ if (state.comments.length) $("comments-empty").textContent = "No open comments. Tick “Show resolved” to see the others.";
730
+ $("comments-send").disabled = !state.comments.some((c) => c.status !== "resolved");
731
+ $("comment-list").innerHTML = list.map((c) => `
732
+ <li class="cmt ${MATCHED.has(c.loc.status) ? "" : "stale"} ${c.status === "resolved" ? "resolved" : ""}" data-id="${esc(c.id)}">
733
+ <div class="top"><span class="n">C${c.n}</span> ${esc(commentWhere(c))}${c.slide.title ? ` “${esc(c.slide.title.slice(0, 40))}”` : ""}
734
+ <span class="spacer"></span>
735
+ <button class="small" data-act="reply" aria-label="Reply to comment C${c.n}">Reply</button>
736
+ <button class="small" data-act="edit" aria-label="Edit comment C${c.n}">Edit</button>
737
+ <button class="small" data-act="${c.status === "resolved" ? "open" : "resolved"}">${c.status === "resolved" ? "Reopen" : "Resolve"}</button>
738
+ <button class="small" data-act="delete" aria-label="Delete comment C${c.n}">×</button></div>
739
+ ${editing?.id === c.id && !editing.reply ? editBox(c, "Save", `Text of comment C${c.n}`) : `<p class="text">${esc(c.text)}</p>`}
740
+ ${(c.replies || []).map((r) => `<p class="reply ${r.by === "you" ? "mine" : ""}"><b>${esc(WHO[r.by] || r.by || "Agent")}:</b> ${esc(r.text)}</p>`).join("")}
741
+ ${editing?.id === c.id && editing.reply ? editBox(c, "Reply", `Reply to comment C${c.n}`) : ""}
742
+ ${c.status === "resolved" && c.resolvedBy && c.resolvedBy !== "you" ? `<p class="done">✓ Resolved by the ${esc(c.resolvedBy)}${c.resolution ? `: ${esc(c.resolution)}` : ""}</p>` : ""}
743
+ ${FLAG[c.loc.status] && c.status !== "resolved" ? `<p class="flag">${esc(FLAG[c.loc.status])}</p>` : ""}
744
+ </li>`).join("");
745
+ const box = editing && $("comment-list").querySelector("textarea.edit");
746
+ if (box) { box.focus(); box.setSelectionRange(box.value.length, box.value.length); }
747
+ else editing = null; // the comment is gone, or hidden as resolved
748
+ }
749
+ async function saveEdit() {
750
+ const { id, reply } = editing, text = editing.text.trim();
751
+ if (!text) return toast(reply ? "Write the reply first." : "A comment cannot be empty: delete it with × instead.");
752
+ editing = null;
753
+ if (!(await commentApi("comment-update", reply ? { id, reply: text } : { id, text }))) { editing = { id, text, reply }; renderComments(); }
754
+ }
755
+ const editBox = (c, save, label) => `<textarea class="edit" rows="3" aria-label="${label}">${esc(editing.text)}</textarea>
756
+ <div class="edit-actions"><button class="small" data-act="save">${save}</button> <button class="small" data-act="cancel">Cancel</button>
757
+ <span class="muted">⌘↩ ${save === "Save" ? "saves" : "sends"}, Esc cancels</span></div>`;
758
+ function toggleComments(show = $("comments-panel").hidden) {
759
+ $("comments-panel").hidden = !show;
760
+ $("comments-btn").setAttribute("aria-expanded", String(show));
761
+ }
762
+ function openComment(id) {
763
+ const c = state.comments.find((x) => x.id === id);
764
+ if (!c) return;
765
+ if (c.status === "resolved" && !showResolved) { showResolved = true; $("comments-resolved").checked = true; renderComments(); }
766
+ toggleComments(true);
767
+ const li = $("comment-list").querySelector(`[data-id="${CSS.escape(id)}"]`);
768
+ li?.scrollIntoView({ block: "nearest" });
769
+ li?.classList.add("flash");
770
+ setTimeout(() => li?.classList.remove("flash"), 1200);
771
+ }
772
+ async function commentApi(p, b) {
773
+ const res = await api(p, b);
774
+ const d = await res.json().catch(() => ({}));
775
+ if (!res.ok) { toast(d.error || `Could not save the comment (${res.status}).`); return null; }
776
+ applyComments(d);
777
+ return d;
778
+ }
779
+ async function addComment() {
780
+ const text = $("input").value.trim();
781
+ if (!text) return toast("Write the comment in the box first, then press Comment.");
782
+ if (state.pins.length > 1) return toast("A comment goes on one pin (or none, for the whole slide): remove the others first.");
783
+ const s = state.slides[state.current];
784
+ const d = await commentApi("comment", { text, pin: state.pins[0] || null, slide: s ? { index: state.current, eid: s.eid } : null });
785
+ if (!d) return;
786
+ $("input").value = "";
787
+ autosize();
788
+ state.pins = [];
789
+ pinsChanged();
790
+ openComment(d.id); // the panel opens on the new comment, so it is seen where it was kept
791
+ }
792
+ $("comment").onclick = addComment;
793
+ $("comments-btn").onclick = () => toggleComments();
794
+ $("comments-resolved").onchange = (e) => {
795
+ showResolved = e.target.checked;
796
+ try { localStorage.setItem("editor.comments.resolved", showResolved ? "1" : "0"); } catch {}
797
+ renderComments();
798
+ };
799
+ $("comment-list").addEventListener("click", (e) => {
800
+ const li = e.target.closest(".cmt");
801
+ const c = li && state.comments.find((x) => x.id === li.dataset.id);
802
+ if (!c || e.target.closest("textarea")) return;
803
+ const act = e.target.closest("[data-act]")?.dataset.act;
804
+ if (act === "edit") { editing = { id: c.id, text: c.text }; return renderComments(); }
805
+ if (act === "reply") { editing = { id: c.id, text: "", reply: true }; return renderComments(); }
806
+ if (act === "cancel") { editing = null; return renderComments(); }
807
+ if (act === "save") return saveEdit();
808
+ if (act === "delete") { if (confirm(`Delete comment C${c.n}?`)) commentApi("comment-delete", { id: c.id }); return; }
809
+ if (act) return commentApi("comment-update", { id: c.id, status: act });
810
+ if (c.loc.index == null) return;
811
+ if (c.loc.index !== state.current) goto(c.loc.index);
812
+ if (c.loc.eid) setTimeout(() => main?.bridge.highlight(c.loc.eid), 80);
813
+ });
814
+ $("comment-list").addEventListener("input", (e) => { if (editing && e.target.matches("textarea.edit")) editing.text = e.target.value; });
815
+ $("comment-list").addEventListener("keydown", (e) => {
816
+ if (!editing || !e.target.matches("textarea.edit")) return;
817
+ if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); editing = null; renderComments(); }
818
+ else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); saveEdit(); }
819
+ });
820
+ $("comments-send").onclick = async () => {
821
+ const res = await api("comments-request");
822
+ const d = await res.json().catch(() => ({}));
823
+ if (!res.ok) return toast(d.error || "Could not read the comments.");
824
+ if (!d.text) return toast("There are no open comments.");
825
+ const now = $("input").value.trim();
826
+ $("input").value = now ? `${now}\n\n${d.text}` : d.text;
827
+ autosize();
828
+ $("input").focus();
829
+ toast("The open comments are in the message box: read them over, then Send. Resolve each one once it is done.", { ms: 6000 });
830
+ };
831
+
685
832
  /* ---- Chat ----------------------------------------------------------------------------------------- */
686
833
  const chatEl = $("chat");
687
834
  const entryEls = new Map();
@@ -805,11 +952,17 @@ $("shot-close").onclick = () => $("shot-dialog").close();
805
952
  // Esc closes it (the browser does that for a modal dialog), and so does a click beside the picture
806
953
  $("shot-dialog").addEventListener("click", (e) => { if (e.target === $("shot-dialog")) $("shot-dialog").close(); });
807
954
 
955
+ /* A Quarto deck is rendered from its source: no hand edits, ever (the next render would undo them) */
956
+ const quartoWhy = () => state.status?.source ? `This deck is rendered by Quarto from ${state.status.source}: ask the assistant for changes, or edit ${state.status.source}.` : null;
957
+ function applyEditable(bridge = main?.bridge) {
958
+ bridge?.setEditable(!state.busy && !quartoWhy(), quartoWhy());
959
+ document.body.classList.toggle("quarto", !!quartoWhy());
960
+ }
808
961
  function setBusy(busy) {
809
962
  state.busy = busy;
810
963
  $("notes").readOnly = busy; // hand edits wait until the assistant has finished
811
- $("working").hidden = !busy;
812
- main?.bridge.setEditable(!busy);
964
+ $("working").hidden = !busy && !state.status?.rendering;
965
+ applyEditable();
813
966
  $("send").hidden = busy;
814
967
  $("stop").hidden = !busy;
815
968
  }
@@ -860,11 +1013,23 @@ $("new-chat").onclick = async () => {
860
1013
  if (!confirm("Start a new conversation? The assistant forgets this chat; your slides, and Undo and Redo, stay as they are.")) return;
861
1014
  await api("new-chat", {});
862
1015
  };
1016
+ // the message box grows with its text from inputMin (set by dragging its top edge) up to 220 px or inputMin
1017
+ const INPUT_H = 96, INPUT_MIN = 48;
1018
+ let inputMin = (() => { try { return +localStorage.getItem("editor.inputHeight") || INPUT_H; } catch { return INPUT_H; } })();
863
1019
  function autosize() {
864
1020
  const t = $("input");
865
1021
  t.style.height = "auto";
866
- t.style.height = Math.min(220, Math.max(64, t.scrollHeight + 2)) + "px";
1022
+ t.style.height = Math.min(Math.max(220, inputMin), Math.max(inputMin, t.scrollHeight + 2)) + "px";
1023
+ }
1024
+ function setInputHeight(h) {
1025
+ const max = Math.max(INPUT_MIN, document.querySelector(".chat-pane").clientHeight * 0.6);
1026
+ inputMin = Math.round(Math.min(max, Math.max(INPUT_MIN, h)));
1027
+ $("input-split").setAttribute("aria-valuenow", String(inputMin));
1028
+ try { localStorage.setItem("editor.inputHeight", String(inputMin)); } catch {}
1029
+ autosize();
867
1030
  }
1031
+ splitter($("input-split"), () => $("input").offsetHeight, setInputHeight, INPUT_H);
1032
+ autosize();
868
1033
  $("input").addEventListener("input", autosize);
869
1034
  $("input").addEventListener("keydown", (e) => {
870
1035
  if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); send(); }
@@ -916,7 +1081,7 @@ function applyStatus(s) {
916
1081
  // the deck's name in the header ("ocean-example", or "talk.html" for a named deck file), its full path on hover
917
1082
  if (s.deck) {
918
1083
  const folder = s.deck.split(/[\\/]/).filter(Boolean).pop() || s.deck;
919
- const name = s.deckFile && s.deckFile !== "index.html" ? s.deckFile : folder;
1084
+ const name = s.source || (s.deckFile && s.deckFile !== "index.html" ? s.deckFile : folder);
920
1085
  $("deck-name").textContent = name;
921
1086
  $("deck-btn").title = `${s.deck}/${s.deckFile || "index.html"}${FRONT ? " (click for other decks)" : ""}`;
922
1087
  if ($("deck-file")) $("deck-file").textContent = s.deckFile || "index.html";
@@ -929,7 +1094,10 @@ function applyStatus(s) {
929
1094
  renderEffortMenu();
930
1095
  $("new-chat").disabled = !!s.busy;
931
1096
  $("stop").hidden = !s.busy || !!s.switching;
932
- $("working-text").textContent = s.switching ? "Updating model…" : s.preparing ? "Taking the screenshot…" : "The assistant is working…";
1097
+ $("working-text").textContent = s.switching ? "Updating model…" : s.preparing ? "Taking the screenshot…" : s.busy ? "The assistant is working…"
1098
+ : s.rendering ? `Rendering ${s.source} with Quarto…` : "";
1099
+ $("working").hidden = !s.busy && !s.rendering;
1100
+ if (s.source) $("deck-source").textContent = s.source;
933
1101
  $("conn").classList.toggle("on", !!s.running);
934
1102
  $("conn").title = s.running ? "The selected backend is running" : "The selected backend starts with the next message";
935
1103
  // only the selected backend's availability matters: Codex missing is no news to someone using Claude
@@ -1005,7 +1173,9 @@ function connect() {
1005
1173
  d.chat.forEach(renderEntry);
1006
1174
  applyStatus(d.status);
1007
1175
  scrollChat();
1176
+ api("comments").then((r) => r.json()).then(applyComments).catch(() => {});
1008
1177
  },
1178
+ comments: (v) => applyComments(v),
1009
1179
  chat(e) {
1010
1180
  const change = (e.kind === "turn" || e.kind === "edit") && e.turnId;
1011
1181
  if (change) state.turns.set(e.turnId, { id: e.turnId, undone: false });
@@ -1239,6 +1409,7 @@ $("provider-switch").addEventListener("close", async () => {
1239
1409
  let deleting = false;
1240
1410
  async function requestDelete(eid) {
1241
1411
  if (deleting || !eid) return;
1412
+ if (quartoWhy()) return toast(quartoWhy());
1242
1413
  if (state.busy) return toast("The assistant is working. Wait until it has finished.");
1243
1414
  deleting = true;
1244
1415
  try {
package/public/index.html CHANGED
@@ -91,25 +91,43 @@
91
91
  </span>
92
92
  <span class="head-actions">
93
93
  <span id="conn" class="conn" title="Backend session"></span>
94
+ <button id="comments-btn" class="small" aria-expanded="false" aria-controls="comments-panel"
95
+ title="Review comments: notes on the slides, kept for later">Comments <span id="comments-count" class="count"></span></button>
94
96
  <button id="new-chat" class="small" title="Start a new conversation">New chat</button>
95
97
  </span>
96
98
  </div>
99
+ <section id="comments-panel" class="comments-panel" aria-label="Review comments" hidden>
100
+ <div class="comments-head">
101
+ <strong>Comments</strong>
102
+ <label class="check"><input type="checkbox" id="comments-resolved"> Show resolved</label>
103
+ <span class="spacer"></span>
104
+ <button id="comments-send" class="small" title="Put the open comments into the message box as one request">Process Comments</button>
105
+ </div>
106
+ <p id="comments-empty" class="muted comments-empty">No comments yet. Write a note in the box below, pin an element (or not, for the
107
+ whole slide) and press <strong>Comment</strong> instead of Send: it is kept here, and on the slide, for later.</p>
108
+ <ol id="comment-list" class="comment-list"></ol>
109
+ <p id="comments-file" class="muted comments-file"></p>
110
+ </section>
97
111
  <div id="chat" class="chat" aria-live="polite">
98
112
  <div class="empty" id="chat-empty">
99
113
  <p><strong>Pin, then ask.</strong></p>
100
114
  <p>Press <kbd>P</kbd> and click elements on the slide. Each click gets a number. Then write what to change, e.g.
101
115
  <em>“1: align with 2, make 3 smaller”</em>.</p>
102
- <p>Or edit yourself: click to select, drag to move, pull a handle to resize, arrow keys to nudge,
116
+ <p class="hand-only">Or edit yourself: click to select, drag to move, pull a handle to resize, arrow keys to nudge,
103
117
  double-click to change the text. A selected element is what the assistant takes “this” to mean.</p>
104
118
  <p>Add figures, audio, a PDF or a screenshot with <strong>+</strong>, or drag and drop or paste a file; the assistant can put it on a slide
105
119
  or, for a paper or report, take the details from it.</p>
106
- <p>The assistant edits <code id="deck-file">index.html</code>, <code>theme.css</code> and the deck's other files, checks the layout, and the slide updates here.
120
+ <p class="hand-only">The assistant edits <code id="deck-file">index.html</code>, <code>theme.css</code> and the deck's other files, checks the layout, and the slide updates here.
107
121
  <code>brand.css</code> is locked.</p>
122
+ <p class="quarto-only">This deck is made with Quarto: the assistant edits <code id="deck-source"></code> and its stylesheets, and PinDeck renders
123
+ the deck after every change. Edit the source yourself in any editor; the slide updates here too.</p>
108
124
  </div>
109
125
  </div>
110
126
  <div id="drop-hint" class="drop-hint" hidden><div>Drop figures, audio or PDFs to attach them</div></div>
111
127
  <div id="working" class="working" hidden><span class="spinner"></span> <span id="working-text">The assistant is working…</span></div>
112
128
  <div class="composer">
129
+ <div id="input-split" class="notes-split" role="separator" aria-orientation="horizontal" aria-controls="input" tabindex="0"
130
+ title="Drag to resize the message box (double-click: default size)" aria-label="Resize the message box"></div>
113
131
  <div id="pin-tray" class="pin-tray"></div>
114
132
  <div id="attach-tray" class="attach-tray"></div>
115
133
  <textarea id="input" rows="3" placeholder="Ask for a change… refer to pins by number (1, 2, …)"></textarea>
@@ -125,6 +143,7 @@
125
143
  <input type="file" id="file-input" multiple accept="image/*,audio/*,.svg,.pdf,application/pdf" hidden>
126
144
  <span class="spacer"></span>
127
145
  <button id="clear-pins" class="small" hidden>Clear pins</button>
146
+ <button id="comment" title="Keep the text as a review comment on the pin (or on the slide), instead of sending it">Comment</button>
128
147
  <button id="send" class="primary">Send</button>
129
148
  <button id="stop" class="danger" hidden>Stop</button>
130
149
  </div>