privateboard 0.1.2 → 0.1.3

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.
@@ -112,9 +112,7 @@
112
112
  // ── Provider catalogue ─────────────────────────────────
113
113
  // Model providers shown on step 2. OpenRouter leads — it's the
114
114
  // universal router that unlocks every model from a single key, so
115
- // it's the lowest-friction first stop for new users. Anthropic
116
- // (Claude) is temporarily withheld; bring it back when the
117
- // direct-Anthropic flow is ready.
115
+ // it's the lowest-friction first stop for new users.
118
116
  // `slug` matches /api/keys/{slug} on the backend.
119
117
  const KEY_PROVIDERS = [
120
118
  {
@@ -125,6 +123,14 @@
125
123
  help: "openrouter.ai/keys",
126
124
  helpUrl: "https://openrouter.ai/keys",
127
125
  },
126
+ {
127
+ slug: "anthropic",
128
+ label: "Claude",
129
+ sub: "Anthropic",
130
+ placeholder: "sk-ant-…",
131
+ help: "console.anthropic.com",
132
+ helpUrl: "https://console.anthropic.com/settings/keys",
133
+ },
128
134
  {
129
135
  slug: "openai",
130
136
  label: "ChatGPT",
@@ -151,6 +157,7 @@
151
157
  * and the Next-button enable state. */
152
158
  let providerConfigured = {
153
159
  openrouter: false,
160
+ anthropic: false,
154
161
  openai: false,
155
162
  google: false,
156
163
  };
@@ -403,7 +410,7 @@
403
410
  <div class="onb-field">
404
411
  <div class="onb-field-label" data-onb-field-label>${escape(active.label)} API key</div>
405
412
  <div class="onb-input-wrap">
406
- <input class="onb-input" data-onb-key type="password" placeholder="${escape(active.placeholder)}" autocomplete="off" spellcheck="false" value="${escape(inputValue)}">
413
+ <input class="onb-input" data-onb-key type="password" placeholder="${escape(active.placeholder)}" autocomplete="one-time-code" data-lpignore="true" data-1p-ignore="true" data-form-type="other" spellcheck="false" value="${escape(inputValue)}">
407
414
  <button type="button" class="onb-input-reveal" data-onb-reveal aria-label="Show key" aria-pressed="false">show</button>
408
415
  </div>
409
416
  ${status}
@@ -598,7 +605,23 @@
598
605
  if (typeof window.boardroomModelsRefresh === "function") {
599
606
  refreshes.push(Promise.resolve(window.boardroomModelsRefresh()).catch(() => {}));
600
607
  }
601
- Promise.all(refreshes).finally(() => { if (continuation) continuation(); });
608
+ Promise.all(refreshes).finally(() => {
609
+ if (continuation) {
610
+ continuation();
611
+ } else {
612
+ // Default skip path · the user dismissed onboarding without
613
+ // picking a starter or convene-your-own. Explicitly land
614
+ // them on the new-room composer. Without this, whatever
615
+ // composer mode the dashboard happened to settle into during
616
+ // boot stays put — and on first-run flows that's
617
+ // occasionally "agent" instead of "room", since the order
618
+ // of restore() / app.init() / refreshAgents isn't strictly
619
+ // guaranteed and the agents-tab restorer can win the race.
620
+ if (window.app && typeof window.app.setComposerMode === "function") {
621
+ window.app.setComposerMode("room");
622
+ }
623
+ }
624
+ });
602
625
  }
603
626
 
604
627
  async function createDemoRoom(spec) {
@@ -630,11 +653,15 @@
630
653
  function openConveneAfter() {
631
654
  setTimeout(() => {
632
655
  // Convene-overlay was retired in favour of the inline composer.
633
- // Fall back to closing the active room so the composer shows; if
634
- // app.closeRoom isn't ready yet, no-op (the user is on the
635
- // dashboard already and will see it on next interaction).
656
+ // Use setComposerMode("room") (not closeRoom) so we explicitly
657
+ // pin the new-room composer · closeRoom inherits whatever
658
+ // composerMode is set, and during a boot race that flag can be
659
+ // "agent", which would land the user on the new-agent composer
660
+ // instead of the new-room one.
636
661
  try {
637
- if (window.app && typeof window.app.closeRoom === "function") {
662
+ if (window.app && typeof window.app.setComposerMode === "function") {
663
+ window.app.setComposerMode("room");
664
+ } else if (window.app && typeof window.app.closeRoom === "function") {
638
665
  window.app.closeRoom();
639
666
  } else if (typeof window.openConveneOverlay === "function") {
640
667
  window.openConveneOverlay();
@@ -2,10 +2,11 @@
2
2
  QUOTE CTA · selection-driven follow-up
3
3
  ═══════════════════════════════════════════
4
4
  When the user selects text inside a director's message bubble,
5
- a small floating bar appears above the selection with two
6
- actions: ask a follow-up (opens an overlay), or react with
7
- "love it" (one-click, no typing). Both produce a user message
8
- that quotes the selected snippet via markdown blockquote.
5
+ a small floating bar appears above the selection with three
6
+ actions: Probe (opens an overlay), Second (one-click), or Save
7
+ (bookmark to chairman's notes). Probe / Second produce a user
8
+ message that quotes the snippet via markdown blockquote; Save
9
+ is a personal bookmark with no room interaction.
9
10
 
10
11
  Visual vocabulary mirrors the rest of the app: panel-2 surface,
11
12
  lime accent, mono micro-type for the action labels. Per the
@@ -78,9 +79,52 @@
78
79
  padding: 8px 13px;
79
80
  white-space: nowrap;
80
81
  }
81
- .qcta.qcta-readonly .qcta-btn { display: none; }
82
+ /* Adjourned-room state · Probe / Second hide (they post to a
83
+ closed room), but Save stays — bookmarking from a finished
84
+ session is a primary use case. */
85
+ .qcta.qcta-readonly .qcta-btn:not(.qcta-btn-save) { display: none; }
82
86
  .qcta.qcta-readonly .qcta-hint { display: inline-flex; align-items: center; }
83
87
 
88
+ /* ─── Save toast ─────────────────────────────────────────────
89
+ Lightweight feedback after a successful POST /api/notes (or a
90
+ failure). Bottom-center anchored, fades in/out. Lime for ok,
91
+ red-tinted for error. Click to dismiss early. */
92
+ .qcta-toast {
93
+ position: fixed;
94
+ bottom: 24px;
95
+ left: 50%;
96
+ transform: translate(-50%, 8px);
97
+ z-index: 1600;
98
+ background: var(--panel, #131312);
99
+ border: 0.5px solid var(--lime, #6FB572);
100
+ color: var(--text, #C8C5BE);
101
+ font-family: var(--mono, "Inter", system-ui, sans-serif);
102
+ font-size: 11.5px;
103
+ font-weight: 500;
104
+ letter-spacing: 0.04em;
105
+ padding: 9px 16px;
106
+ pointer-events: none;
107
+ opacity: 0;
108
+ transition: opacity 0.16s ease-out, transform 0.16s ease-out;
109
+ white-space: nowrap;
110
+ box-shadow: 0 14px 30px -14px rgba(0, 0, 0, 0.55);
111
+ }
112
+ .qcta-toast.open {
113
+ opacity: 1;
114
+ transform: translate(-50%, 0);
115
+ pointer-events: auto;
116
+ cursor: pointer;
117
+ }
118
+ .qcta-toast.kind-error {
119
+ border-color: #d36a6a;
120
+ color: #f0bdbd;
121
+ }
122
+ .qcta-toast.kind-ok::before {
123
+ content: "✓ ";
124
+ color: var(--lime, #6FB572);
125
+ font-weight: 700;
126
+ }
127
+
84
128
  /* Ask-follow-up overlay · backdrop + modal. Same chrome family
85
129
  as openSendChoiceModal (.pc-overlay) so the family stays coherent. */
86
130
  .qask-overlay {
@@ -2,7 +2,7 @@
2
2
  QUOTE CTA · selection-driven follow-up
3
3
  ═══════════════════════════════════════════
4
4
  When the user selects text inside a director's message bubble,
5
- a small floating bar appears above the selection with two
5
+ a small floating bar appears above the selection with three
6
6
  actions:
7
7
 
8
8
  ✎ Probe / 追问 → opens an overlay; user types a question;
@@ -18,11 +18,22 @@
18
18
  the quote, signalling the user co-signs the
19
19
  director's point. Same routing.
20
20
 
21
+ ⌖ Save / 收藏 → one-click; bookmarks the selection to the
22
+ chairman's notes (POST /api/notes). No room
23
+ message is created — this is a personal
24
+ collection, not a room interaction. Works
25
+ even in adjourned rooms (re-reading a
26
+ finished session is a primary use-case).
27
+ Keyboard shortcut: `S` (when a director
28
+ selection is live).
29
+
21
30
  Director scope · selection only counts when both ends sit inside
22
31
  the same `article.msg` whose class is neither `user` nor `chair`.
23
32
 
24
- No backend changes · everything rides on existing /api/rooms/:id/
25
- messages POST and the markdown blockquote renderer in app.js.
33
+ Probe / Second ride existing /api/rooms/:id/messages. Save POSTs
34
+ to /api/notes with quote + sentence-based context + char offsets
35
+ (computed against the bubble's textContent so the in-room overlay
36
+ can re-wrap the same span on next render).
26
37
  */
27
38
  (function () {
28
39
  let cta = null; // floating button bar
@@ -56,15 +67,81 @@
56
67
  // director bubbles (added in app.js messageHtml). Name comes from
57
68
  // the visible .msg-name span in the same message header.
58
69
  const directorId = article.dataset.authorId || "";
70
+ const messageId = article.dataset.messageId || "";
59
71
  const nameEl = article.querySelector(".msg-name");
60
72
  const directorName = nameEl ? nameEl.textContent.trim() : "";
61
- // Adjourned rooms · CTA still shows but in a read-only state with
62
- // a hint instead of buttons. Surfacing the bar (rather than
63
- // silently doing nothing) tells the user "your selection was
64
- // detected" and explains why probe / second aren't available.
73
+ // Adjourned rooms · the room is closed for new replies (Probe /
74
+ // Second are disabled), but the user can still save notes from
75
+ // it re-reading a finished session is a primary use case.
65
76
  const app = window.app;
66
77
  const adjourned = !!(app && app.currentRoom && app.currentRoom.status === "adjourned");
67
- return { article, bubble, range, text, directorId, directorName, adjourned };
78
+
79
+ // Char offsets relative to bubble.textContent · let the in-room
80
+ // overlay (Step 5) wrap the same span on next render. Computed
81
+ // once here so save can fire on either the button click or the
82
+ // `S` keyboard shortcut without re-walking the DOM.
83
+ const offsets = computeOffsets(bubble, range);
84
+
85
+ return {
86
+ article, bubble, range, text, messageId,
87
+ directorId, directorName, adjourned,
88
+ charOffsetStart: offsets.start,
89
+ charOffsetEnd: offsets.end,
90
+ bubbleText: offsets.bubbleText,
91
+ };
92
+ }
93
+
94
+ // Compute the char offset of a Range's start / end relative to a
95
+ // container's textContent. Uses Range.toString().length on a
96
+ // synthetic range that spans [container start → selection point],
97
+ // which honours rendered text the same way textContent does (skips
98
+ // markup, preserves visible characters). Returns -1 / -1 if the
99
+ // walk fails (renderer falls back to no overlay).
100
+ function computeOffsets(container, range) {
101
+ const bubbleText = container.textContent || "";
102
+ try {
103
+ const before = document.createRange();
104
+ before.setStart(container, 0);
105
+ before.setEnd(range.startContainer, range.startOffset);
106
+ const start = before.toString().length;
107
+ const inner = document.createRange();
108
+ inner.setStart(range.startContainer, range.startOffset);
109
+ inner.setEnd(range.endContainer, range.endOffset);
110
+ const end = start + inner.toString().length;
111
+ return { start, end, bubbleText };
112
+ } catch {
113
+ return { start: -1, end: -1, bubbleText };
114
+ }
115
+ }
116
+
117
+ // Sentence-based context expansion · grabs ~1–2 sentences on each
118
+ // side of the quote (capped at MAX_CHARS). Honours both ASCII
119
+ // (.!?) and CJK (。!?) sentence terminators. Falls back to the
120
+ // char cap if no boundary is found within the cap window.
121
+ function expandContext(fullText, quoteStart, quoteEnd) {
122
+ if (!fullText || quoteStart < 0 || quoteEnd < quoteStart) {
123
+ return { before: "", after: "" };
124
+ }
125
+ const MAX_CHARS = 200;
126
+ const SENTENCE_END = /[.!?。!?]/;
127
+ let beforeStart = Math.max(0, quoteStart - MAX_CHARS);
128
+ for (let i = quoteStart - 1; i >= beforeStart; i--) {
129
+ if (SENTENCE_END.test(fullText[i])) {
130
+ beforeStart = Math.min(i + 1, quoteStart);
131
+ break;
132
+ }
133
+ }
134
+ let afterEnd = Math.min(fullText.length, quoteEnd + MAX_CHARS);
135
+ for (let i = quoteEnd; i < afterEnd; i++) {
136
+ if (SENTENCE_END.test(fullText[i])) {
137
+ afterEnd = Math.min(i + 1, fullText.length);
138
+ break;
139
+ }
140
+ }
141
+ return {
142
+ before: fullText.slice(beforeStart, quoteStart),
143
+ after: fullText.slice(quoteEnd, afterEnd),
144
+ };
68
145
  }
69
146
 
70
147
  function lang() {
@@ -83,8 +160,8 @@
83
160
  cta.className = "qcta";
84
161
  cta.setAttribute("role", "toolbar");
85
162
  const t = lang() === "zh"
86
- ? { ask: "追问", love: "附议" }
87
- : { ask: "Probe", love: "Second" };
163
+ ? { ask: "追问", love: "附议", save: "收藏" }
164
+ : { ask: "Probe", love: "Second", save: "Save" };
88
165
  // Inline chat-bubble SVG · uses currentColor so it inherits the
89
166
  // hover lime / base text colour like the ★ glyph does.
90
167
  const askIcon = `
@@ -92,6 +169,13 @@
92
169
  <path d="M2 3 H12 V9 H6.5 L4 11 L4 9 H2 Z"/>
93
170
  </svg>
94
171
  `;
172
+ // Bookmark glyph · matches the All Notes sidebar entry's icon
173
+ // semantics (this action lands in that view).
174
+ const saveIcon = `
175
+ <svg viewBox="0 0 14 14" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="miter" stroke-linecap="square" aria-hidden="true">
176
+ <path d="M3.5 1.5 H10.5 V12.5 L7 9.5 L3.5 12.5 Z"/>
177
+ </svg>
178
+ `;
95
179
  cta.innerHTML = `
96
180
  <button type="button" class="qcta-btn" data-qcta="ask">
97
181
  <span class="ico">${askIcon}</span><span>${t.ask}</span>
@@ -99,6 +183,9 @@
99
183
  <button type="button" class="qcta-btn" data-qcta="second">
100
184
  <span class="ico">★</span><span>${t.love}</span>
101
185
  </button>
186
+ <button type="button" class="qcta-btn qcta-btn-save" data-qcta="save" title="Save to Notes · S">
187
+ <span class="ico">${saveIcon}</span><span>${t.save}</span>
188
+ </button>
102
189
  <span class="qcta-hint" data-qcta-hint></span>
103
190
  `;
104
191
  // Prevent the bar's mousedown from collapsing the selection BEFORE
@@ -108,16 +195,17 @@
108
195
  cta.addEventListener("click", (e) => {
109
196
  const btn = e.target.closest("[data-qcta]");
110
197
  if (!btn) return;
111
- // Read-only state · the bar shows a hint about why instead of
112
- // doing anything. Bail before hideCTA so the user can keep
113
- // reading the hint while their selection stays.
114
- if (cta.classList.contains("qcta-readonly")) return;
115
198
  const action = btn.getAttribute("data-qcta");
199
+ // Read-only state (adjourned rooms) blocks Probe / Second since
200
+ // they post messages to a closed room. Save is exempt — the
201
+ // user is bookmarking for personal review, not interacting.
202
+ if (cta.classList.contains("qcta-readonly") && action !== "save") return;
116
203
  const sel = lastSelection;
117
204
  hideCTA();
118
205
  if (!sel || !sel.text) return;
119
206
  if (action === "ask") openAskOverlay(sel);
120
207
  else if (action === "second") submitSecond(sel);
208
+ else if (action === "save") submitSave(sel);
121
209
  });
122
210
  document.body.appendChild(cta);
123
211
  return cta;
@@ -129,10 +217,14 @@
129
217
  text: ctx.text,
130
218
  directorId: ctx.directorId,
131
219
  directorName: ctx.directorName,
220
+ messageId: ctx.messageId,
221
+ charOffsetStart: ctx.charOffsetStart,
222
+ charOffsetEnd: ctx.charOffsetEnd,
223
+ bubbleText: ctx.bubbleText,
132
224
  };
133
- // Read-only state · adjourned room. Hide buttons, show hint text
134
- // so the user knows the selection was detected but the room is
135
- // closed for new replies.
225
+ // Read-only state · adjourned room. Hide Probe / Second (they
226
+ // post to a closed room); Save stays available review-mode
227
+ // bookmarking is a primary use case for adjourned sessions.
136
228
  bar.classList.toggle("qcta-readonly", !!ctx.adjourned);
137
229
  const hint = bar.querySelector("[data-qcta-hint]");
138
230
  if (hint) {
@@ -190,8 +282,35 @@
190
282
  window.addEventListener("scroll", hideCTA, true);
191
283
  document.addEventListener("keydown", (e) => {
192
284
  if (e.key === "Escape") hideCTA();
285
+
286
+ // `S` shortcut · save current selection to Notes. Only fires
287
+ // when (a) a director-scoped selection is live, (b) no modifier
288
+ // keys are pressed (Cmd/Ctrl/Alt would clobber browser
289
+ // shortcuts), (c) the user isn't typing into an input. Skipping
290
+ // when an input/textarea is focused avoids hijacking the `s`
291
+ // key during composer typing — the qcta bar wouldn't have
292
+ // shown for a non-director selection anyway.
293
+ if ((e.key === "s" || e.key === "S")
294
+ && !e.metaKey && !e.ctrlKey && !e.altKey
295
+ && !isEditableTarget(e.target)
296
+ && lastSelection
297
+ && lastSelection.text
298
+ && cta && cta.classList.contains("open")) {
299
+ e.preventDefault();
300
+ const sel = lastSelection;
301
+ hideCTA();
302
+ submitSave(sel);
303
+ }
193
304
  });
194
305
 
306
+ function isEditableTarget(node) {
307
+ if (!node) return false;
308
+ const tag = (node.tagName || "").toLowerCase();
309
+ if (tag === "input" || tag === "textarea") return true;
310
+ if (node.isContentEditable) return true;
311
+ return false;
312
+ }
313
+
195
314
  // ── Ask-follow-up overlay ────────────────────────────────────
196
315
  function openAskOverlay(sel) {
197
316
  closeAskOverlay();
@@ -315,6 +434,85 @@
315
434
  routeSend(body, mentions);
316
435
  }
317
436
 
437
+ // ── Save to Notes ─────────────────────────────────────────────
438
+ // POST /api/notes with quote + sentence-based context + char
439
+ // offsets. No room interaction — this is a personal bookmark.
440
+ async function submitSave(sel) {
441
+ const app = window.app;
442
+ const room = app && app.currentRoom;
443
+ if (!room || !room.id) {
444
+ toast(lang() === "zh" ? "无法保存:未打开房间" : "Can't save: no room open", "error");
445
+ return;
446
+ }
447
+ if (!sel.messageId) {
448
+ toast(lang() === "zh" ? "无法保存:未识别原文位置" : "Can't save: source not identified", "error");
449
+ return;
450
+ }
451
+ const ctx = expandContext(
452
+ sel.bubbleText || "",
453
+ typeof sel.charOffsetStart === "number" ? sel.charOffsetStart : -1,
454
+ typeof sel.charOffsetEnd === "number" ? sel.charOffsetEnd : -1,
455
+ );
456
+ const payload = {
457
+ roomId: room.id,
458
+ messageId: sel.messageId,
459
+ quoteText: sel.text,
460
+ contextBefore: ctx.before,
461
+ contextAfter: ctx.after,
462
+ charOffsetStart: sel.charOffsetStart,
463
+ charOffsetEnd: sel.charOffsetEnd,
464
+ authorName: sel.directorName,
465
+ };
466
+ try {
467
+ const res = await fetch("/api/notes", {
468
+ method: "POST",
469
+ headers: { "Content-Type": "application/json" },
470
+ body: JSON.stringify(payload),
471
+ });
472
+ if (!res.ok) {
473
+ const j = await res.json().catch(() => ({}));
474
+ throw new Error(j.error || ("HTTP " + res.status));
475
+ }
476
+ const note = await res.json();
477
+ toast(lang() === "zh" ? "已收藏到笔记" : "Saved to Notes", "ok");
478
+
479
+ // Tell the rest of the app a note was created · sidebar badge
480
+ // refreshes its count, in-room overlay (Step 5) wraps the
481
+ // saved span. Listeners that don't exist yet are no-ops.
482
+ try {
483
+ document.dispatchEvent(new CustomEvent("note:created", { detail: { note } }));
484
+ } catch { /* */ }
485
+ } catch (err) {
486
+ toast(
487
+ (lang() === "zh" ? "保存失败:" : "Save failed: ") + (err && err.message ? err.message : err),
488
+ "error",
489
+ );
490
+ }
491
+ }
492
+
493
+ // Lightweight toast · the app already has `app.notify(...)` in
494
+ // some paths but not all; using a self-contained one keeps this
495
+ // module independent. Lime for ok, red-tinted for error. Auto-
496
+ // dismisses after 1.8s; click to dismiss early.
497
+ let toastEl = null;
498
+ let toastTimer = null;
499
+ function toast(msg, kind) {
500
+ if (!toastEl) {
501
+ toastEl = document.createElement("div");
502
+ toastEl.className = "qcta-toast";
503
+ toastEl.addEventListener("click", () => toastEl.classList.remove("open"));
504
+ document.body.appendChild(toastEl);
505
+ }
506
+ toastEl.classList.remove("kind-ok", "kind-error");
507
+ toastEl.classList.add("kind-" + (kind === "error" ? "error" : "ok"));
508
+ toastEl.textContent = msg;
509
+ toastEl.classList.add("open");
510
+ if (toastTimer) clearTimeout(toastTimer);
511
+ toastTimer = setTimeout(() => {
512
+ if (toastEl) toastEl.classList.remove("open");
513
+ }, 1800);
514
+ }
515
+
318
516
  /** Routing matrix:
319
517
  * paused → auto-resume the room first, then send
320
518
  * live + agent mid-turn → open the interrupt-or-queue modal