privateboard 0.1.4 → 0.1.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateboard",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "PrivateBoard · your private board meeting, on call. Local-first, multi-agent thinking amplifier.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,6 +17,8 @@
17
17
  const MODEL_LABELS = {
18
18
  "opus-4-7": { name: "Claude Opus 4.7", provider: "Anthropic" },
19
19
  "sonnet-4-6": { name: "Claude Sonnet 4.6", provider: "Anthropic" },
20
+ "opus-4-6": { name: "Claude Opus 4.6", provider: "Anthropic" },
21
+ "opus-4-6-fast": { name: "Claude Opus 4.6 Fast", provider: "Anthropic" },
20
22
  "haiku-4-5": { name: "Claude Haiku 4.5", provider: "Anthropic" },
21
23
  "gpt-5-5": { name: "GPT-5.5", provider: "OpenAI" },
22
24
  "gpt-5-4": { name: "GPT-5.4", provider: "OpenAI" },
@@ -586,6 +586,8 @@
586
586
  const MODEL_LABELS = {
587
587
  "sonnet-4-6": { name: "Sonnet 4.6", deck: "balanced · default" },
588
588
  "opus-4-7": { name: "Opus 4.7", deck: "deep reasoning" },
589
+ "opus-4-6": { name: "Opus 4.6", deck: "prior-gen flagship" },
590
+ "opus-4-6-fast": { name: "Opus 4.6 Fast", deck: "faster 4.6 · same intelligence" },
589
591
  "haiku-4-5": { name: "Haiku 4.5", deck: "fast · low-cost" },
590
592
  "gpt-5-5": { name: "GPT-5.5", deck: "flagship · 1M ctx" },
591
593
  "gpt-5-4": { name: "GPT-5.4", deck: "general · 1M ctx" },
@@ -1837,9 +1839,11 @@
1837
1839
  // OpenRouter id, so verify + room calls hit the right model.
1838
1840
  const PROFILE_MODELS = [
1839
1841
  // Anthropic
1840
- { v: "opus-4-7", name: "Claude Opus 4.7", provider: "Anthropic", deck: "deep reasoning · default" },
1841
- { v: "sonnet-4-6", name: "Claude Sonnet 4.6", provider: "Anthropic", deck: "balanced · 1M ctx" },
1842
- { v: "haiku-4-5", name: "Claude Haiku 4.5", provider: "Anthropic", deck: "fast · low-cost" },
1842
+ { v: "opus-4-7", name: "Claude Opus 4.7", provider: "Anthropic", deck: "deep reasoning · default" },
1843
+ { v: "sonnet-4-6", name: "Claude Sonnet 4.6", provider: "Anthropic", deck: "balanced · 1M ctx" },
1844
+ { v: "opus-4-6", name: "Claude Opus 4.6", provider: "Anthropic", deck: "prior-gen flagship" },
1845
+ { v: "opus-4-6-fast", name: "Claude Opus 4.6 Fast", provider: "Anthropic", deck: "faster 4.6 · same intelligence" },
1846
+ { v: "haiku-4-5", name: "Claude Haiku 4.5", provider: "Anthropic", deck: "fast · low-cost" },
1843
1847
  // OpenAI
1844
1848
  { v: "gpt-5-5-pro", name: "GPT-5.5 Pro", provider: "OpenAI", deck: "flagship · 1M ctx" },
1845
1849
  { v: "gpt-5-5", name: "GPT-5.5", provider: "OpenAI", deck: "1M ctx" },
package/public/app.js CHANGED
@@ -19,6 +19,8 @@
19
19
  const MODEL_LABELS = {
20
20
  "sonnet-4-6": "Sonnet 4.6",
21
21
  "opus-4-7": "Opus 4.7",
22
+ "opus-4-6": "Opus 4.6",
23
+ "opus-4-6-fast": "Opus 4.6 Fast",
22
24
  "haiku-4-5": "Haiku 4.5",
23
25
  "gpt-5-5": "GPT-5.5",
24
26
  "gpt-5-4": "GPT-5.4",
@@ -41,9 +43,11 @@
41
43
  * enough context (label · provider · short deck) to choose at a
42
44
  * glance. */
43
45
  const AGENT_COMPOSER_MODELS = [
44
- { v: "opus-4-7", label: "Claude Opus 4.7", provider: "Anthropic", deck: "deep reasoning" },
45
- { v: "sonnet-4-6", label: "Claude Sonnet 4.6", provider: "Anthropic", deck: "balanced · default" },
46
- { v: "haiku-4-5", label: "Claude Haiku 4.5", provider: "Anthropic", deck: "fast · low-cost" },
46
+ { v: "opus-4-7", label: "Claude Opus 4.7", provider: "Anthropic", deck: "deep reasoning" },
47
+ { v: "sonnet-4-6", label: "Claude Sonnet 4.6", provider: "Anthropic", deck: "balanced · default" },
48
+ { v: "opus-4-6", label: "Claude Opus 4.6", provider: "Anthropic", deck: "prior-gen flagship" },
49
+ { v: "opus-4-6-fast", label: "Claude Opus 4.6 Fast", provider: "Anthropic", deck: "faster 4.6 · same intelligence" },
50
+ { v: "haiku-4-5", label: "Claude Haiku 4.5", provider: "Anthropic", deck: "fast · low-cost" },
47
51
  { v: "gpt-5-5-pro", label: "GPT-5.5 Pro", provider: "OpenAI", deck: "flagship · 1M ctx" },
48
52
  { v: "gpt-5-5", label: "GPT-5.5", provider: "OpenAI", deck: "1M ctx" },
49
53
  { v: "gpt-5-4", label: "GPT-5.4", provider: "OpenAI", deck: "general · 1M ctx" },
@@ -4155,10 +4159,25 @@
4155
4159
  stats.probeCount > 0 ? `<span class="sa-chip"><span class="sa-chip-mark">✎</span>${stats.probeCount} ${this.escape(t.probed)}</span>` : "",
4156
4160
  ].filter(Boolean).join("");
4157
4161
 
4162
+ // Cap default-visible upvoted points at 2 · the rest collapse
4163
+ // behind a [+ N more] toggle. Long lists were dominating the
4164
+ // analytics tile; capping keeps the section as a tight strip
4165
+ // and lets the user opt in.
4166
+ const VALUE_PREVIEW_CAP = 2;
4167
+ const moreLabel = (n) => isZh ? `[ + 展开剩余 ${n} 条 ]` : `[ + show ${n} more ]`;
4168
+ const lessLabel = isZh ? "[ 收起 ]" : "[ collapse ]";
4158
4169
  const upvotedHtml = stats.upvotedPoints.length > 0
4159
- ? `<ul class="sa-points">${stats.upvotedPoints.map((p) =>
4160
- `<li class="sa-point"><span class="sa-point-mark">▲</span><span class="sa-point-body">${this.escape(p.body)}</span></li>`
4161
- ).join("")}</ul>`
4170
+ ? (() => {
4171
+ const items = stats.upvotedPoints.map((p, i) => {
4172
+ const cls = i >= VALUE_PREVIEW_CAP ? "sa-point sa-point-extra" : "sa-point";
4173
+ return `<li class="${cls}"><span class="sa-point-mark">▲</span><span class="sa-point-body">${this.escape(p.body)}</span></li>`;
4174
+ }).join("");
4175
+ const overflow = stats.upvotedPoints.length - VALUE_PREVIEW_CAP;
4176
+ const toggle = overflow > 0
4177
+ ? `<button type="button" class="sa-points-toggle" data-sa-toggle aria-expanded="false" data-more-label="${this.escape(moreLabel(overflow))}" data-less-label="${this.escape(lessLabel)}">${this.escape(moreLabel(overflow))}</button>`
4178
+ : "";
4179
+ return `<ul class="sa-points" data-sa-points>${items}</ul>${toggle}`;
4180
+ })()
4162
4181
  : "";
4163
4182
  const valueBlock = (valueChips || upvotedHtml)
4164
4183
  ? `
@@ -4231,6 +4250,21 @@
4231
4250
  } else {
4232
4251
  briefCard.parentNode.insertBefore(block, briefCard);
4233
4252
  }
4253
+
4254
+ // Wire the [+ show N more] toggle for the upvoted points list.
4255
+ const toggleBtn = block.querySelector("[data-sa-toggle]");
4256
+ if (toggleBtn) {
4257
+ const list = block.querySelector("[data-sa-points]");
4258
+ toggleBtn.addEventListener("click", () => {
4259
+ const expanded = toggleBtn.getAttribute("aria-expanded") === "true";
4260
+ const next = !expanded;
4261
+ toggleBtn.setAttribute("aria-expanded", String(next));
4262
+ if (list) list.classList.toggle("sa-points-expanded", next);
4263
+ toggleBtn.textContent = next
4264
+ ? toggleBtn.dataset.lessLabel
4265
+ : toggleBtn.dataset.moreLabel;
4266
+ });
4267
+ }
4234
4268
  },
4235
4269
 
4236
4270
  renderPausedBar() {
@@ -8674,20 +8708,87 @@
8674
8708
  .replace(/&[a-z]+;/gi, " "); // HTML entities
8675
8709
  const cjk = stripped.match(/[一-鿿㐀-䶿豈-﫿぀-ゟ゠-ヿ]/g);
8676
8710
  const cjkCount = cjk ? cjk.length : 0;
8677
- // CJK-dominant docs: count chars (the "字数" the user reads off
8678
- // a manuscript). The 30% threshold catches mixed zh-en docs
8679
- // where the body is mostly Chinese with English brand names —
8680
- // the natural unit there is still characters, not words.
8681
- if (cjkCount >= stripped.length * 0.3 && cjkCount > 80) {
8682
- const fmt = cjkCount.toLocaleString("en-US");
8683
- return `~${fmt} 字`;
8684
- }
8685
- // English / Latin: whitespace-split, ignore empty tokens.
8686
- const words = stripped.trim().split(/\s+/).filter((w) => w.length > 0);
8687
- const n = words.length;
8688
- if (n === 0) return null;
8689
- const fmt = n.toLocaleString("en-US");
8690
- return n === 1 ? "1 word" : `${fmt} words`;
8711
+ const isCjk = cjkCount >= stripped.length * 0.3 && cjkCount > 80;
8712
+ let count;
8713
+ let label;
8714
+ if (isCjk) {
8715
+ count = cjkCount;
8716
+ label = `~${count.toLocaleString("en-US")} 字`;
8717
+ } else {
8718
+ const words = stripped.trim().split(/\s+/).filter((w) => w.length > 0);
8719
+ count = words.length;
8720
+ if (count === 0) return null;
8721
+ label = count === 1 ? "1 word" : `${count.toLocaleString("en-US")} words`;
8722
+ }
8723
+ // Tone-aware sweet band · brainstorm recaps run lean (concrete
8724
+ // ideas, not deep analysis); standard rooms (constructive /
8725
+ // debate) land in the middle; research / critique rooms shoulder
8726
+ // a denser shape (assumptions + scenarios + indicators + threats
8727
+ // to validity all naturally lengthen the body).
8728
+ const tone = (this.currentRoom?.mode || "constructive").toLowerCase();
8729
+ const bandKind = tone === "brainstorm"
8730
+ ? "lean"
8731
+ : (tone === "research" || tone === "critique" ? "dense" : "standard");
8732
+ // Brainstorm `lean` was originally 600-1500 zh / 400-1000 en —
8733
+ // calibrated against "quick recap" briefs with 1-2 directors.
8734
+ // That undercounted real brainstorm rooms: 3-4 directors × 2-3
8735
+ // rounds × 2-3 ideas-per-turn easily produces 12-20 ideas, each
8736
+ // worth 80-150 words (concept + why-it-matters + what-it-opens).
8737
+ // 1500-2200 words / 2000-3000 字 is a healthy, idea-dense
8738
+ // brainstorm — bumping the band so that lands in `sweet`, not
8739
+ // `dense`. Standard / dense bands unchanged.
8740
+ const bands = isCjk
8741
+ ? ({
8742
+ lean: { sweetLo: 1000, sweetHi: 2200, denseHi: 3800, longHi: 5500 },
8743
+ standard: { sweetLo: 1500, sweetHi: 2800, denseHi: 4500, longHi: 6500 },
8744
+ dense: { sweetLo: 2500, sweetHi: 4500, denseHi: 6500, longHi: 8000 },
8745
+ })[bandKind]
8746
+ : ({
8747
+ lean: { sweetLo: 800, sweetHi: 1600, denseHi: 2800, longHi: 4000 },
8748
+ standard: { sweetLo: 1000, sweetHi: 1800, denseHi: 3000, longHi: 4500 },
8749
+ dense: { sweetLo: 1800, sweetHi: 3000, denseHi: 4500, longHi: 5500 },
8750
+ })[bandKind];
8751
+ let tier;
8752
+ if (count < bands.sweetLo) tier = "thin";
8753
+ else if (count <= bands.sweetHi) tier = "sweet";
8754
+ else if (count <= bands.denseHi) tier = "dense";
8755
+ else if (count <= bands.longHi) tier = "long";
8756
+ else tier = "too-long";
8757
+ return { label, tier, count, isCjk, tone, bands };
8758
+ },
8759
+
8760
+ /** Tooltip explaining what the brief-card word-count chip's colour
8761
+ * means · tone-aware so the user understands why a 2,500-word
8762
+ * brainstorm recap reads "dense" while the same length in a
8763
+ * research note reads "sweet." */
8764
+ _briefWordCountTip(wc) {
8765
+ if (!wc) return "";
8766
+ const isZh = wc.isCjk;
8767
+ const toneLabel = ({
8768
+ brainstorm: isZh ? "脑暴" : "brainstorm",
8769
+ constructive: isZh ? "构建" : "constructive",
8770
+ debate: isZh ? "辩论" : "debate",
8771
+ research: isZh ? "研究" : "research",
8772
+ critique: isZh ? "评审" : "critique",
8773
+ })[wc.tone] || wc.tone;
8774
+ const range = `${wc.bands.sweetLo.toLocaleString("en-US")}-${wc.bands.sweetHi.toLocaleString("en-US")}`;
8775
+ const unit = isZh ? "字" : "words";
8776
+ const copy = isZh
8777
+ ? {
8778
+ "thin": `偏短 · ${toneLabel} 模式甜点区约 ${range} ${unit}`,
8779
+ "sweet": `甜点区 · ${toneLabel} 模式正合适`,
8780
+ "dense": `偏密集 · 已超出 ${toneLabel} 模式甜点区,但仍可读`,
8781
+ "long": `偏长 · 接近"会被存档而非阅读"的临界`,
8782
+ "too-long": `过长 · 大概率会被快速跳读`,
8783
+ }
8784
+ : {
8785
+ "thin": `Lean · ${toneLabel} sweet zone is ${range} ${unit}`,
8786
+ "sweet": `Sweet zone for ${toneLabel} · most read-through-able length`,
8787
+ "dense": `Dense · past the ${toneLabel} sweet zone, still readable`,
8788
+ "long": `Long · approaching "filed instead of read"`,
8789
+ "too-long": `Too long · likely to be skimmed, not read`,
8790
+ };
8791
+ return copy[wc.tier] || "";
8691
8792
  },
8692
8793
 
8693
8794
  /** Render the brief version tab strip · shared by both the error
@@ -8938,12 +9039,13 @@
8938
9039
  ? `<div class="brief-info brief-info-generating">${this.renderBriefStages(b)}</div>`
8939
9040
  : (() => {
8940
9041
  const wc = this._briefWordCount(b);
9042
+ const tip = this._briefWordCountTip(wc);
8941
9043
  return `<div class="brief-info">
8942
9044
  <div class="brief-kicker">// filed by ${this.escape(this.currentChair?.name || "the chair")}</div>
8943
9045
  <h2 class="brief-title" data-brief-title>${this.escape(b.title || "(untitled)")}</h2>
8944
9046
  <div class="brief-meta-row">
8945
9047
  <span class="brief-meta-line">${this.currentMembers.length} authors</span>
8946
- ${wc ? `<span class="brief-meta-sep" aria-hidden="true">·</span><span class="brief-meta-line brief-meta-words">${this.escape(wc)}</span>` : ""}
9048
+ ${wc ? `<span class="brief-meta-sep" aria-hidden="true">·</span><span class="brief-meta-line brief-meta-words is-${this.escape(wc.tier)}" title="${this.escape(tip)}">${this.escape(wc.label)}</span>` : ""}
8947
9049
  <div class="brief-signed">
8948
9050
  <div class="brief-signed-avatars">${signed}</div>
8949
9051
  </div>
@@ -9742,16 +9844,14 @@
9742
9844
  }
9743
9845
  return;
9744
9846
  }
9745
- // Follow-up picker · row click toggles the director
9746
- const followupPickRow = e.target.closest("[data-followup-pick-id]");
9747
- if (followupPickRow) {
9748
- // Suppress default checkbox toggle so we drive state via our
9749
- // single source of truth (_followupCastState).
9750
- e.preventDefault();
9751
- const id = followupPickRow.getAttribute("data-followup-pick-id");
9752
- if (id) app.toggleFollowUpCastDirector(id);
9753
- return;
9754
- }
9847
+ // Follow-up picker · row click is handled via the `change` event
9848
+ // on the inner checkbox (registered separately below), mirroring
9849
+ // the inline composer's pattern. The earlier `preventDefault` +
9850
+ // direct toggle approach left the checkbox's visual state stuck:
9851
+ // preventDefault cancelled the native toggle but the programmatic
9852
+ // `cb.checked = on` raced with the bubble in a way that didn't
9853
+ // visually re-mark the checkbox. The change-event flow lets the
9854
+ // browser draw the check, then syncs state from the new cb state.
9755
9855
  // Click on a follow-up tile in the parent room's "Follow-up rooms"
9756
9856
  // strip · navigate to the child. Click on the parent banner of a
9757
9857
  // follow-up room · navigate up.
@@ -10359,6 +10459,18 @@
10359
10459
  const id = row && row.getAttribute("data-composer-pick-id");
10360
10460
  if (id) app.toggleComposerDirector(id);
10361
10461
  });
10462
+ // Follow-up overlay's director picker · same pattern as the inline
10463
+ // composer above. Listen for `change` on the inner checkbox so the
10464
+ // browser draws the check first, then sync `_followupCastState`
10465
+ // from the post-toggle state. Was previously click + preventDefault,
10466
+ // which left the checkbox visually unticked.
10467
+ document.addEventListener("change", (e) => {
10468
+ const cb = e.target;
10469
+ if (!cb || !cb.matches || !cb.matches('[data-followup-pick-id] input[type="checkbox"]')) return;
10470
+ const row = cb.closest("[data-followup-pick-id]");
10471
+ const id = row && row.getAttribute("data-followup-pick-id");
10472
+ if (id) app.toggleFollowUpCastDirector(id);
10473
+ });
10362
10474
  document.addEventListener("click", (e) => {
10363
10475
  const btn = e.target.closest("[data-send-button]");
10364
10476
  if (!btn) return;