privateboard 0.1.36 → 0.1.37

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/dist/version.d.ts CHANGED
@@ -12,6 +12,6 @@
12
12
  * number ends up surfaced in the user-facing footer or banner. Keep
13
13
  * this file as the canonical source — every callsite reads from here.
14
14
  */
15
- declare const VERSION = "0.1.36";
15
+ declare const VERSION = "0.1.37";
16
16
 
17
17
  export { VERSION };
package/dist/version.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/version.ts
4
- var VERSION = "0.1.36";
4
+ var VERSION = "0.1.37";
5
5
  export {
6
6
  VERSION
7
7
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/version.ts"],"sourcesContent":["/**\n * Single source of truth for the app version.\n *\n * Imported by `cli.ts` (CLI banner / `--version`), `server.ts` (the\n * `/health` payload + the `/api/version` endpoint), and bundled into\n * the frontend via the version endpoint. Bump alongside `package.json`\n * on every release — the existing `npm version <patch|minor|major>`\n * + commit pattern updates package.json automatically; this file\n * needs the matching manual bump.\n *\n * If two strings drift (bumped one but not the other), the wrong\n * number ends up surfaced in the user-facing footer or banner. Keep\n * this file as the canonical source — every callsite reads from here.\n */\nexport const VERSION = \"0.1.36\";\n"],"mappings":";;;AAcO,IAAM,UAAU;","names":[]}
1
+ {"version":3,"sources":["../src/version.ts"],"sourcesContent":["/**\n * Single source of truth for the app version.\n *\n * Imported by `cli.ts` (CLI banner / `--version`), `server.ts` (the\n * `/health` payload + the `/api/version` endpoint), and bundled into\n * the frontend via the version endpoint. Bump alongside `package.json`\n * on every release — the existing `npm version <patch|minor|major>`\n * + commit pattern updates package.json automatically; this file\n * needs the matching manual bump.\n *\n * If two strings drift (bumped one but not the other), the wrong\n * number ends up surfaced in the user-facing footer or banner. Keep\n * this file as the canonical source — every callsite reads from here.\n */\nexport const VERSION = \"0.1.37\";\n"],"mappings":";;;AAcO,IAAM,UAAU;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateboard",
3
- "version": "0.1.36",
3
+ "version": "0.1.37",
4
4
  "description": "PrivateBoard · your private board meeting, on call. Local-first, multi-agent thinking amplifier.",
5
5
  "type": "module",
6
6
  "main": "electron-entry.cjs",
@@ -30,7 +30,8 @@
30
30
  "electron:build-ts": "tsup && tsc -p electron/tsconfig.json",
31
31
  "electron:build-icon": "node scripts/build-app-icon.mjs",
32
32
  "electron:dev": "npm run electron:rebuild && npm run electron:build-ts && electron .",
33
- "electron:dist": "electron-builder install-app-deps && npm run electron:build-ts && npm run electron:build-icon && electron-builder --mac --publish always",
33
+ "electron:dist": "electron-builder install-app-deps && npm run electron:build-ts && npm run electron:build-icon && electron-builder --mac --publish always && npm run release:mac:finalize",
34
+ "release:mac:finalize": "node scripts/finalize-mac-release.mjs",
34
35
  "electron:dist:local": "electron-builder install-app-deps && npm run electron:build-ts && npm run electron:build-icon && electron-builder --mac --publish never",
35
36
  "electron:dist:unsigned": "CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder install-app-deps && npm run electron:build-ts && npm run electron:build-icon && CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --mac --publish never --config.mac.hardenedRuntime=false --config.mac.notarize=false"
36
37
  },
package/public/app.js CHANGED
@@ -148,6 +148,15 @@
148
148
  * appendMessageDom, drained in _revealPrewarmedBubble, baked into
149
149
  * messageHtml so every render preserves the hidden state. */
150
150
  _prewarmedHidden: new Set(),
151
+ /** Server-authoritative "currently-visible speaker" messageId.
152
+ * Pushed via queue-update SSE (orchestrator/room.ts pumpQueue).
153
+ * Drives the .streaming class gate in `updateMessageBodyDom` so
154
+ * only the one director the server marks as the active turn
155
+ * shows the typing-dots / pulse animation, even in the brief
156
+ * same-frame window where A's finalize and B's appended SSE
157
+ * events both reach the DOM. Null between turns / before first
158
+ * turn. */
159
+ currentActiveMessageId: null,
151
160
  /** Mini-player anchor · when set, the cross-room bar persists
152
161
  * for this voice room throughout its whole live/paused life
153
162
  * (between speakers, during votes / clarify, idle phases) —
@@ -1164,6 +1173,12 @@
1164
1173
  : (data.members || []).map((m) => ({ ...m, removedAt: null }));
1165
1174
  this.currentChair = data.chair || null;
1166
1175
  this.currentQueue = data.queue || [];
1176
+ // Reset to null on room open · the queue-update SSE will push
1177
+ // the real value if a director is currently speaking. The
1178
+ // initial GET /api/rooms/:id snapshot doesn't carry this field
1179
+ // (it's a transient pump state, not persisted to messages), so
1180
+ // we wait for the first SSE tick.
1181
+ this.currentActiveMessageId = null;
1167
1182
  this.currentRound = data.round || { spoken: 0, total: 0 };
1168
1183
  this.currentKeyPoints = data.keyPoints || [];
1169
1184
  // Reset the round-table HUD log when (re)opening a room. Past
@@ -1402,6 +1417,7 @@
1402
1417
  // re-opened (since `openRoom` re-sets it from data.chair),
1403
1418
  // but the empty + composer states looked broken.
1404
1419
  this.currentQueue = [];
1420
+ this.currentActiveMessageId = null;
1405
1421
  this.currentRound = { spoken: 0, total: 0 };
1406
1422
  this.currentKeyPoints = [];
1407
1423
  this.rtChairLog = [];
@@ -1978,6 +1994,15 @@
1978
1994
  msg.body = data.body;
1979
1995
  msg.meta = data.meta || {};
1980
1996
  }
1997
+ // Server cleared meta.preWarmed (pump just consumed this
1998
+ // speaker · it is now the visible active turn). Drain from
1999
+ // the hidden set BEFORE renderChat so the article re-paints
2000
+ // without `data-prewarmed`. Otherwise the CSS `display:none`
2001
+ // would persist across this re-render until the next event.
2002
+ if (data.meta && data.meta.preWarmed === false
2003
+ && this._prewarmedHidden && this._prewarmedHidden.has(data.messageId)) {
2004
+ this._revealPrewarmedBubble(data.messageId);
2005
+ }
1981
2006
  // Re-render via a chat repaint · the tool-use renderer is
1982
2007
  // selected by meta.kind so it rebuilds with the new status.
1983
2008
  this.renderChat();
@@ -1987,6 +2012,32 @@
1987
2012
  const data = JSON.parse(e.data);
1988
2013
  this.currentQueue = data.queue || [];
1989
2014
  if (data.round) this.currentRound = data.round;
2015
+ // activeMessageId · server tells us which director's bubble
2016
+ // is the currently-visible "speaking" turn. Two consumers:
2017
+ // (1) `updateMessageBodyDom` gates the streaming animation
2018
+ // on this id (suppresses the brief text-mode flash where
2019
+ // A's finalize and B's append cross paths in the same rAF),
2020
+ // (2) a hidden pre-warmed bubble whose id matches gets
2021
+ // revealed here (defense in depth alongside the message-
2022
+ // updated meta.preWarmed:false signal — whichever arrives
2023
+ // first wins).
2024
+ const prevActive = this.currentActiveMessageId || null;
2025
+ this.currentActiveMessageId = (data && typeof data.activeMessageId === "string")
2026
+ ? data.activeMessageId
2027
+ : (data && data.activeMessageId === null ? null : prevActive);
2028
+ if (this.currentActiveMessageId
2029
+ && this._prewarmedHidden
2030
+ && this._prewarmedHidden.has(this.currentActiveMessageId)) {
2031
+ this._revealPrewarmedBubble(this.currentActiveMessageId);
2032
+ }
2033
+ // If the active id changed AND the streaming class is now on
2034
+ // the wrong bubble, refresh both. Only affects bubbles that
2035
+ // are still in streaming state; finalized bubbles never carry
2036
+ // .streaming so they're not re-touched.
2037
+ if (prevActive !== this.currentActiveMessageId) {
2038
+ this._refreshStreamingClassForId(prevActive);
2039
+ this._refreshStreamingClassForId(this.currentActiveMessageId);
2040
+ }
1990
2041
  this.renderQueue();
1991
2042
  });
1992
2043
 
@@ -3542,12 +3593,53 @@
3542
3593
  `;
3543
3594
  }).join("");
3544
3595
  document.body.appendChild(pop);
3545
- // Anchor under the wrapper · `.ap-model-picker` is
3546
- // `position: fixed` so we set top/left relative to viewport.
3596
+ // Anchor relative to the wrapper · `.ap-model-picker` is
3597
+ // `position: fixed` so we set top/left in viewport coords.
3598
+ // Flip-above logic · the house-style dropdown sits in the
3599
+ // SECOND row of the modal and the modal is centred in the
3600
+ // viewport, so the trigger often lands close to the lower
3601
+ // half of the screen. A naive "always below" placement gets
3602
+ // clipped at the viewport bottom (visually: half the list
3603
+ // hidden under the chrome). We measure the rendered popover
3604
+ // height and choose the side with more room — preferring
3605
+ // below by default, flipping above when below is tight and
3606
+ // above has more space. The base `.ap-model-picker` rule
3607
+ // caps height at 60vh + scrolls inside, so a long menu still
3608
+ // fits even after flip.
3547
3609
  const r = wrap.getBoundingClientRect();
3548
- pop.style.top = `${Math.round(r.bottom + 4)}px`;
3549
3610
  pop.style.left = `${Math.round(r.left)}px`;
3550
3611
  pop.style.width = `${Math.round(r.width)}px`;
3612
+ // First paint to measure; the visible top is intentionally
3613
+ // off-screen until the position decision lands (avoids a
3614
+ // one-frame flash at the wrong location).
3615
+ pop.style.top = "-9999px";
3616
+ pop.style.visibility = "hidden";
3617
+ const PAD = 8;
3618
+ const GAP = 4;
3619
+ const popH = pop.getBoundingClientRect().height;
3620
+ const spaceBelow = Math.max(0, window.innerHeight - r.bottom - PAD);
3621
+ const spaceAbove = Math.max(0, r.top - PAD);
3622
+ let topPx;
3623
+ if (popH + GAP <= spaceBelow) {
3624
+ // Fits below comfortably.
3625
+ topPx = Math.round(r.bottom + GAP);
3626
+ } else if (popH + GAP <= spaceAbove) {
3627
+ // Fits above comfortably · flip.
3628
+ topPx = Math.round(r.top - popH - GAP);
3629
+ } else if (spaceAbove > spaceBelow) {
3630
+ // Neither side fits the full list — pick the bigger side
3631
+ // (above) and cap max-height so the popover's own internal
3632
+ // scroll (`.ap-model-picker { overflow-y: auto }`) handles
3633
+ // the overflow rather than the viewport edge clipping us.
3634
+ topPx = PAD;
3635
+ pop.style.maxHeight = `${spaceAbove}px`;
3636
+ } else {
3637
+ // Stay below · same cap-and-scroll trick.
3638
+ topPx = Math.round(r.bottom + GAP);
3639
+ pop.style.maxHeight = `${spaceBelow}px`;
3640
+ }
3641
+ pop.style.top = `${topPx}px`;
3642
+ pop.style.visibility = "";
3551
3643
  // Dismiss handlers · attached synchronously, but the trigger's
3552
3644
  // own wrap is whitelisted so the click that OPENED the popover
3553
3645
  // (which fires AFTER mousedown · same user gesture) doesn't
@@ -6964,6 +7056,25 @@
6964
7056
  // when the agent profile takes focus, and otherwise the agent
6965
7057
  // rows shouldn't be highlighted at all.
6966
7058
  }
7059
+ // Collapsed mini-rail · light the "rooms" icon when an actual
7060
+ // room is the main view; clear it for composers (the new-room /
7061
+ // new-agent mini icon carries the highlight there instead).
7062
+ this.setMiniRailContext(roomId !== null ? "rooms" : null);
7063
+ },
7064
+
7065
+ /** Highlight the rooms / agents switcher in the collapsed mini
7066
+ * rail. Mutually exclusive · passing `null` clears both (used by
7067
+ * the reports / notes / composer destinations, which light their
7068
+ * own mini icon via the shared trigger attributes). Mirrors the
7069
+ * full sidebar's tab selection but scoped to the icon rail so the
7070
+ * collapsed sidebar still shows "where you are". */
7071
+ setMiniRailContext(ctx) {
7072
+ document.querySelectorAll('.mini-tab[data-mini-tab="rooms"]').forEach((el) => {
7073
+ el.classList.toggle("active", ctx === "rooms");
7074
+ });
7075
+ document.querySelectorAll('.mini-tab[data-mini-tab="agents"]').forEach((el) => {
7076
+ el.classList.toggle("active", ctx === "agents");
7077
+ });
6967
7078
  },
6968
7079
 
6969
7080
  /** Sidebar focus when an agent profile takes the main view. The
@@ -6993,6 +7104,9 @@
6993
7104
  document.querySelectorAll(".agent-row").forEach((r) => {
6994
7105
  r.classList.toggle("active", r.dataset.agentProfile === slug);
6995
7106
  });
7107
+ // Collapsed mini-rail · an agent profile is the main view → light
7108
+ // the agents switcher icon.
7109
+ this.setMiniRailContext("agents");
6996
7110
  // Reset composer mode so subsequent transitions are clean — the
6997
7111
  // user is no longer "creating" anything.
6998
7112
  this.composerMode = "room";
@@ -7255,17 +7369,23 @@
7255
7369
  // matches what the user picked in settings; otherwise fall back
7256
7370
  // to the initial-letter chip we shipped before AvatarSkill
7257
7371
  // existed.
7258
- const av = document.querySelector("[data-user-avatar]");
7259
- if (av) {
7260
- const seed = this.prefs?.avatarSeed;
7261
- if (seed && window.AvatarSkill && typeof window.AvatarSkill.generate === "function") {
7372
+ // Sync every avatar slot · the full sidebar foot AND the
7373
+ // collapsed mini-rail foot both carry [data-user-avatar], so
7374
+ // querySelectorAll keeps them identical regardless of which is
7375
+ // currently visible.
7376
+ const seed = this.prefs?.avatarSeed;
7377
+ const avHtml = (seed && window.AvatarSkill && typeof window.AvatarSkill.generate === "function")
7378
+ ? window.AvatarSkill.generate(seed)
7379
+ : null;
7380
+ document.querySelectorAll("[data-user-avatar]").forEach((av) => {
7381
+ if (avHtml) {
7262
7382
  av.classList.add("has-pixel-av");
7263
- av.innerHTML = window.AvatarSkill.generate(seed);
7383
+ av.innerHTML = avHtml;
7264
7384
  } else {
7265
7385
  av.classList.remove("has-pixel-av");
7266
7386
  av.textContent = initial;
7267
7387
  }
7268
- }
7388
+ });
7269
7389
  const nm = document.querySelector("[data-user-name]");
7270
7390
  if (nm) nm.textContent = name;
7271
7391
  const mt = document.querySelector("[data-user-meta]");
@@ -8124,6 +8244,9 @@
8124
8244
  document.querySelectorAll("[data-notes-trigger].active").forEach((el) => el.classList.remove("active"));
8125
8245
  document.querySelectorAll("[data-search-trigger].active").forEach((el) => el.classList.remove("active"));
8126
8246
  document.querySelectorAll("[data-reports-trigger]").forEach((el) => el.classList.add("active"));
8247
+ // Reports is its own destination · clear the mini-rail rooms /
8248
+ // agents switchers so only the reports mini icon reads active.
8249
+ this.setMiniRailContext(null);
8127
8250
 
8128
8251
  // Persist the view via URL hash so refresh / back-button restore
8129
8252
  // both the page content AND the sidebar highlight. replaceState
@@ -8610,6 +8733,9 @@
8610
8733
  document.querySelectorAll("[data-reports-trigger].active").forEach((el) => el.classList.remove("active"));
8611
8734
  document.querySelectorAll("[data-search-trigger].active").forEach((el) => el.classList.remove("active"));
8612
8735
  document.querySelectorAll("[data-notes-trigger]").forEach((el) => el.classList.add("active"));
8736
+ // Notes is its own destination · clear the mini-rail rooms /
8737
+ // agents switchers so only the notes mini icon reads active.
8738
+ this.setMiniRailContext(null);
8613
8739
 
8614
8740
  if (location.hash !== "#/notes") {
8615
8741
  try { history.replaceState(null, "", "#/notes"); } catch { /* ignore */ }
@@ -13660,7 +13786,6 @@
13660
13786
  // tokens are not routed through `_t()`.
13661
13787
  const statusWord = r.status !== "live" ? String(r.status).toUpperCase() : "";
13662
13788
  head.innerHTML = `
13663
- <button type="button" class="room-head-expand" data-sidebar-expand title="${this.escape(this._t("sidebar_expand"))}" aria-label="${this.escape(this._t("sidebar_expand"))}" data-tip="${this.escape(this._t("sidebar_expand"))}"></button>
13664
13789
  <div class="room-info">
13665
13790
  <div class="room-kicker">
13666
13791
  <span class="kicker-num">// ROOM #${r.number}</span>
@@ -15530,14 +15655,36 @@
15530
15655
  const isChairCard =
15531
15656
  article.classList.contains("chair-direct") ||
15532
15657
  article.classList.contains("chair-intervention");
15533
- if (empty && streaming) {
15658
+ // Single-active-speaker gate · the streaming animation belongs
15659
+ // only to the director whose turn is currently visible per the
15660
+ // server's queue-update activeMessageId field. In text mode this
15661
+ // erases the brief same-frame flash where A's finalize and B's
15662
+ // appended SSE events both run their DOM mutations in the same
15663
+ // rAF — without the gate, both bubbles render `.streaming` and
15664
+ // the user reads "two directors speaking at once." Chair cards
15665
+ // and non-agent messages (user, system) keep their own
15666
+ // streaming semantics; the gate only applies to director
15667
+ // (agent.roleKind === "director") bubbles. We can't read
15668
+ // roleKind here without a getter, so use the same heuristic
15669
+ // updateMessageBodyDom already trusts: a regular .msg article
15670
+ // that is NOT a chair-card is a director or user; user
15671
+ // messages don't carry the streaming class anyway, so this
15672
+ // narrows safely.
15673
+ const isAgentArticle =
15674
+ article.classList.contains("msg") &&
15675
+ !article.classList.contains("user") &&
15676
+ !isChairCard;
15677
+ const activeId = this.currentActiveMessageId || null;
15678
+ const speakingGate = !isAgentArticle || !activeId || messageId === activeId;
15679
+ const wantStreaming = !!streaming && speakingGate;
15680
+ if (empty && wantStreaming) {
15534
15681
  bubble.innerHTML = this.thinkingHtml();
15535
15682
  article.classList.add("thinking");
15536
15683
  article.classList.add(isChairCard ? "is-streaming" : "streaming");
15537
15684
  } else {
15538
15685
  bubble.innerHTML = this.renderBody(display);
15539
15686
  article.classList.toggle("thinking", false);
15540
- article.classList.toggle(isChairCard ? "is-streaming" : "streaming", !!streaming);
15687
+ article.classList.toggle(isChairCard ? "is-streaming" : "streaming", wantStreaming);
15541
15688
  // Re-apply chairman's-notes highlights · the bubble's
15542
15689
  // innerHTML rewrite above wipes any previously-injected
15543
15690
  // .note-highlight spans. Skip mid-stream (offsets won't match
@@ -15548,6 +15695,21 @@
15548
15695
  }
15549
15696
  },
15550
15697
 
15698
+ /** Re-evaluate the streaming class on a single bubble using its
15699
+ * current `meta.streaming` and the current activeMessageId. Used
15700
+ * by the queue-update SSE handler to flip the speaking animation
15701
+ * between A (finalized) and B (newly active) without waiting
15702
+ * for a token tick. No-op when the message isn't streaming or
15703
+ * the article isn't in the DOM yet. */
15704
+ _refreshStreamingClassForId(messageId) {
15705
+ if (!messageId) return;
15706
+ const msg = this.currentMessages.find((m) => m.id === messageId);
15707
+ if (!msg) return;
15708
+ const streaming = !!(msg.meta && msg.meta.streaming);
15709
+ if (!streaming) return;
15710
+ this.updateMessageBodyDom(messageId, msg.body || "", true);
15711
+ },
15712
+
15551
15713
  /** Count of prior non-empty messages this speaker would see as context. */
15552
15714
  contextCountAt(messageId) {
15553
15715
  const idx = this.currentMessages.findIndex((m) => m.id === messageId);
@@ -16212,12 +16374,22 @@
16212
16374
 
16213
16375
  const empty = !displayBody;
16214
16376
  const streaming = m.meta && m.meta.streaming === true;
16377
+ // Single-active-speaker gate · mirrors `updateMessageBodyDom`.
16378
+ // For director (non-chair, non-user, agent) messages, only the
16379
+ // bubble whose id matches the server's activeMessageId may
16380
+ // wear the .streaming + .thinking classes. This keeps the
16381
+ // first-paint and the live-update class state consistent so
16382
+ // the text-mode "two directors flashing" race vanishes.
16383
+ const isDirectorMsg = !isUser && !isChair;
16384
+ const activeId = this.currentActiveMessageId || null;
16385
+ const speakingGate = !isDirectorMsg || !activeId || m.id === activeId;
16386
+ const wantStreaming = !!streaming && speakingGate;
16215
16387
  const stateCls = [];
16216
- if (streaming) stateCls.push("streaming");
16217
- if (empty && streaming && !isUser) stateCls.push("thinking");
16388
+ if (wantStreaming) stateCls.push("streaming");
16389
+ if (empty && wantStreaming) stateCls.push("thinking");
16218
16390
  if (metaKind) stateCls.push(`kind-${metaKind}`);
16219
16391
 
16220
- const bubbleHtml = (empty && streaming && !isUser)
16392
+ const bubbleHtml = (empty && wantStreaming)
16221
16393
  ? this.thinkingHtml()
16222
16394
  : this.renderBody(displayBody);
16223
16395
 
@@ -0,0 +1 @@
1
+ <svg viewBox="120 128 272 272" xmlns="http://www.w3.org/2000/svg" shape-rendering="crispEdges" preserveAspectRatio="xMidYMid meet"><rect x="192" y="160" width="16" height="16" fill="#ce8a5a"/><rect x="208" y="160" width="16" height="16" fill="#ce8a5a"/><rect x="224" y="160" width="16" height="16" fill="#a35a32"/><rect x="240" y="160" width="16" height="16" fill="#a35a32"/><rect x="256" y="160" width="16" height="16" fill="#a35a32"/><rect x="272" y="160" width="16" height="16" fill="#a35a32"/><rect x="288" y="160" width="16" height="16" fill="#a35a32"/><rect x="304" y="160" width="16" height="16" fill="#a35a32"/><rect x="176" y="176" width="16" height="16" fill="#ce8a5a"/><rect x="192" y="176" width="16" height="16" fill="#ce8a5a"/><rect x="208" y="176" width="16" height="16" fill="#a35a32"/><rect x="224" y="176" width="16" height="16" fill="#a35a32"/><rect x="240" y="176" width="16" height="16" fill="#a35a32"/><rect x="256" y="176" width="16" height="16" fill="#a35a32"/><rect x="272" y="176" width="16" height="16" fill="#a35a32"/><rect x="288" y="176" width="16" height="16" fill="#a35a32"/><rect x="304" y="176" width="16" height="16" fill="#a35a32"/><rect x="320" y="176" width="16" height="16" fill="#a35a32"/><rect x="160" y="192" width="16" height="16" fill="#ce8a5a"/><rect x="176" y="192" width="16" height="16" fill="#ce8a5a"/><rect x="192" y="192" width="16" height="16" fill="#a35a32"/><rect x="208" y="192" width="16" height="16" fill="#a35a32"/><rect x="224" y="192" width="16" height="16" fill="#a35a32"/><rect x="240" y="192" width="16" height="16" fill="#a35a32"/><rect x="256" y="192" width="16" height="16" fill="#a35a32"/><rect x="272" y="192" width="16" height="16" fill="#a35a32"/><rect x="288" y="192" width="16" height="16" fill="#a35a32"/><rect x="304" y="192" width="16" height="16" fill="#a35a32"/><rect x="320" y="192" width="16" height="16" fill="#a35a32"/><rect x="336" y="192" width="16" height="16" fill="#a35a32"/><rect x="160" y="208" width="16" height="16" fill="#a35a32"/><rect x="176" y="208" width="16" height="16" fill="#a35a32"/><rect x="192" y="208" width="16" height="16" fill="#a35a32"/><rect x="208" y="208" width="16" height="16" fill="#a35a32"/><rect x="224" y="208" width="16" height="16" fill="#a35a32"/><rect x="240" y="208" width="16" height="16" fill="#a35a32"/><rect x="256" y="208" width="16" height="16" fill="#a35a32"/><rect x="272" y="208" width="16" height="16" fill="#a35a32"/><rect x="288" y="208" width="16" height="16" fill="#a35a32"/><rect x="304" y="208" width="16" height="16" fill="#a35a32"/><rect x="320" y="208" width="16" height="16" fill="#a35a32"/><rect x="336" y="208" width="16" height="16" fill="#a35a32"/><rect x="160" y="224" width="16" height="16" fill="#a35a32"/><rect x="176" y="224" width="16" height="16" fill="#a35a32"/><rect x="192" y="224" width="16" height="16" fill="#a35a32"/><rect x="208" y="224" width="16" height="16" fill="#1a0805"/><rect x="224" y="224" width="16" height="16" fill="#a35a32"/><rect x="240" y="224" width="16" height="16" fill="#a35a32"/><rect x="256" y="224" width="16" height="16" fill="#a35a32"/><rect x="272" y="224" width="16" height="16" fill="#a35a32"/><rect x="288" y="224" width="16" height="16" fill="#1a0805"/><rect x="304" y="224" width="16" height="16" fill="#a35a32"/><rect x="320" y="224" width="16" height="16" fill="#a35a32"/><rect x="336" y="224" width="16" height="16" fill="#a35a32"/><rect x="160" y="240" width="16" height="16" fill="#a35a32"/><rect x="176" y="240" width="16" height="16" fill="#a35a32"/><rect x="192" y="240" width="16" height="16" fill="#a35a32"/><rect x="208" y="240" width="16" height="16" fill="#a35a32"/><rect x="224" y="240" width="16" height="16" fill="#a35a32"/><rect x="240" y="240" width="16" height="16" fill="#a35a32"/><rect x="256" y="240" width="16" height="16" fill="#a35a32"/><rect x="272" y="240" width="16" height="16" fill="#a35a32"/><rect x="288" y="240" width="16" height="16" fill="#a35a32"/><rect x="304" y="240" width="16" height="16" fill="#a35a32"/><rect x="320" y="240" width="16" height="16" fill="#a35a32"/><rect x="336" y="240" width="16" height="16" fill="#a35a32"/><rect x="160" y="256" width="16" height="16" fill="#a35a32"/><rect x="176" y="256" width="16" height="16" fill="#a35a32"/><rect x="192" y="256" width="16" height="16" fill="#a35a32"/><rect x="208" y="256" width="16" height="16" fill="#a35a32"/><rect x="224" y="256" width="16" height="16" fill="#a35a32"/><rect x="240" y="256" width="16" height="16" fill="#a35a32"/><rect x="256" y="256" width="16" height="16" fill="#a35a32"/><rect x="272" y="256" width="16" height="16" fill="#a35a32"/><rect x="288" y="256" width="16" height="16" fill="#a35a32"/><rect x="304" y="256" width="16" height="16" fill="#a35a32"/><rect x="320" y="256" width="16" height="16" fill="#a35a32"/><rect x="336" y="256" width="16" height="16" fill="#a35a32"/><rect x="160" y="272" width="16" height="16" fill="#5a2a10"/><rect x="176" y="272" width="16" height="16" fill="#5a2a10"/><rect x="192" y="272" width="16" height="16" fill="#5a2a10"/><rect x="208" y="272" width="16" height="16" fill="#5a2a10"/><rect x="224" y="272" width="16" height="16" fill="#5a2a10"/><rect x="240" y="272" width="16" height="16" fill="#cfb56a"/><rect x="256" y="272" width="16" height="16" fill="#cfb56a"/><rect x="272" y="272" width="16" height="16" fill="#5a2a10"/><rect x="288" y="272" width="16" height="16" fill="#5a2a10"/><rect x="304" y="272" width="16" height="16" fill="#5a2a10"/><rect x="320" y="272" width="16" height="16" fill="#5a2a10"/><rect x="336" y="272" width="16" height="16" fill="#5a2a10"/><rect x="160" y="288" width="16" height="16" fill="#a35a32"/><rect x="176" y="288" width="16" height="16" fill="#a35a32"/><rect x="192" y="288" width="16" height="16" fill="#a35a32"/><rect x="208" y="288" width="16" height="16" fill="#a35a32"/><rect x="224" y="288" width="16" height="16" fill="#a35a32"/><rect x="240" y="288" width="16" height="16" fill="#a35a32"/><rect x="256" y="288" width="16" height="16" fill="#a35a32"/><rect x="272" y="288" width="16" height="16" fill="#a35a32"/><rect x="288" y="288" width="16" height="16" fill="#a35a32"/><rect x="304" y="288" width="16" height="16" fill="#a35a32"/><rect x="320" y="288" width="16" height="16" fill="#6e3814"/><rect x="336" y="288" width="16" height="16" fill="#6e3814"/><rect x="160" y="304" width="16" height="16" fill="#a35a32"/><rect x="176" y="304" width="16" height="16" fill="#a35a32"/><rect x="192" y="304" width="16" height="16" fill="#a35a32"/><rect x="208" y="304" width="16" height="16" fill="#a35a32"/><rect x="224" y="304" width="16" height="16" fill="#a35a32"/><rect x="240" y="304" width="16" height="16" fill="#a35a32"/><rect x="256" y="304" width="16" height="16" fill="#a35a32"/><rect x="272" y="304" width="16" height="16" fill="#a35a32"/><rect x="288" y="304" width="16" height="16" fill="#a35a32"/><rect x="304" y="304" width="16" height="16" fill="#6e3814"/><rect x="320" y="304" width="16" height="16" fill="#6e3814"/><rect x="336" y="304" width="16" height="16" fill="#a35a32"/><rect x="176" y="320" width="16" height="16" fill="#a35a32"/><rect x="192" y="320" width="16" height="16" fill="#a35a32"/><rect x="208" y="320" width="16" height="16" fill="#a35a32"/><rect x="224" y="320" width="16" height="16" fill="#a35a32"/><rect x="240" y="320" width="16" height="16" fill="#a35a32"/><rect x="256" y="320" width="16" height="16" fill="#a35a32"/><rect x="272" y="320" width="16" height="16" fill="#a35a32"/><rect x="288" y="320" width="16" height="16" fill="#a35a32"/><rect x="304" y="320" width="16" height="16" fill="#a35a32"/><rect x="320" y="320" width="16" height="16" fill="#6e3814"/><rect x="192" y="336" width="16" height="16" fill="#a35a32"/><rect x="208" y="336" width="16" height="16" fill="#a35a32"/><rect x="288" y="336" width="16" height="16" fill="#a35a32"/><rect x="304" y="336" width="16" height="16" fill="#6e3814"/><rect x="192" y="352" width="16" height="16" fill="#3e1c08"/><rect x="208" y="352" width="16" height="16" fill="#3e1c08"/><rect x="288" y="352" width="16" height="16" fill="#3e1c08"/><rect x="304" y="352" width="16" height="16" fill="#3e1c08"/></svg>
@@ -90,6 +90,12 @@
90
90
  elevationDeg: 24, // soft top-down lean (default 30 was steep, 18 too flat)
91
91
  lookAtY: 0.75, // target slightly above the table top so seated heads centre
92
92
  },
93
+ // Suppress the built-in loading veil · the marketing hero
94
+ // already shows a static poster (`home-3d-poster.*`) which
95
+ // covers the mount → first-frame gap. Layering the veil on
96
+ // top of the poster would just darken it before the canvas
97
+ // takes over.
98
+ loading: false,
93
99
  })) return;
94
100
 
95
101
  // Pull the cast + start the rotating-speaker driver. The mock
package/public/home.html CHANGED
@@ -1622,7 +1622,7 @@
1622
1622
 
1623
1623
  <div class="hero-actions">
1624
1624
  <a href="#install" class="big-btn primary">[ ◆ Convene a Room ]</a>
1625
- <a href="https://github.com/kaysaith1900/privateboard/releases/download/v0.1.34/PrivateBoard-0.1.34-arm64.dmg"
1625
+ <a href="https://github.com/kaysaith1900/privateboard/releases/download/v0.1.36/PrivateBoard-0.1.36-arm64.dmg"
1626
1626
  class="big-btn secondary"
1627
1627
  download
1628
1628
  aria-label="Download PrivateBoard for macOS (Apple Silicon)"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 3v12"/><polyline points="6 11 12 17 18 11"/><path d="M5 21h14"/></svg> Download for Mac</a>
package/public/i18n.js CHANGED
@@ -1199,6 +1199,8 @@ When the room ___, raise an objection.`,
1199
1199
  us_active_llm_save_btn: "Save & activate",
1200
1200
  us_active_llm_save_failed: "Could not save the credential — check the key and try again.",
1201
1201
  us_active_llm_delete_active_confirm: "Removing the active provider — the boardroom will fall back to another added provider, or stop working until you add a new one. Continue?",
1202
+ us_llm_delete_confirm: "Remove this LLM credential? This can't be undone.",
1203
+ us_provider_key_delete_confirm: "Remove this {label} API key? This can't be undone.",
1202
1204
  us_active_llm_switch_confirm: "Switch active LLM provider to {label}? Every director will be reassigned to the new provider's models.",
1203
1205
  voice_cred_section_title: "▸ Voice provider",
1204
1206
  voice_cred_section_deck: "Powers spoken meetings · directors pull voices from the active provider's catalog only.",
@@ -1210,6 +1212,7 @@ When the room ___, raise an objection.`,
1210
1212
  voice_cred_picker_title: "Choose a voice provider",
1211
1213
  voice_cred_switch_confirm: "Switch active voice provider to {label}? Each director's voice will be reshuffled from the new provider's catalog.",
1212
1214
  voice_cred_delete_confirm_active: "Remove the active voice credential? Director voices reshuffle from the next available credential.",
1215
+ voice_cred_delete_confirm: "Remove this voice credential? This can't be undone.",
1213
1216
  voice_cred_save_failed: "Could not save the voice credential — check the key and try again.",
1214
1217
  ap_voice_empty_no_active: "No active voice provider · open Settings to add one",
1215
1218
  search_cred_section_title: "▸ Search provider",
@@ -1222,6 +1225,7 @@ When the room ___, raise an objection.`,
1222
1225
  search_cred_picker_title: "Choose a search provider",
1223
1226
  search_cred_switch_confirm: "Switch active search provider to {label}? Web Search routes through this credential from now on.",
1224
1227
  search_cred_delete_confirm_active: "Remove the active search credential? Web Search rotates to the next available credential, or stops working until you add one.",
1228
+ search_cred_delete_confirm: "Remove this search credential? This can't be undone.",
1225
1229
  search_cred_save_failed: "Could not save the search credential — check the key and try again.",
1226
1230
  us_ws_backend_tag: "WEB SEARCH BACKEND",
1227
1231
  us_ws_backend_deck:
@@ -2424,6 +2428,8 @@ When the room ___, raise an objection.`,
2424
2428
  us_active_llm_save_btn: "保存并启用",
2425
2429
  us_active_llm_save_failed: "保存失败 —— 请检查 key 后重试。",
2426
2430
  us_active_llm_delete_active_confirm: "正在删除当前使用的服务商 —— 删除后将自动切换到下一个已添加的服务商;如果没有其他服务商,系统将无法正常使用。确定继续吗?",
2431
+ us_llm_delete_confirm: "确认删除这条 LLM 凭证?删除后无法恢复。",
2432
+ us_provider_key_delete_confirm: "确认删除 {label} 的 API key?删除后无法恢复。",
2427
2433
  us_active_llm_switch_confirm: "切换当前服务商为 {label}?所有 director 会被重新分配到新服务商的模型。",
2428
2434
  voice_cred_section_title: "▸ 语音模型",
2429
2435
  voice_cred_section_deck: "支撑语音会议 · 所有 director 只从当前生效的语音模型里抽声音。",
@@ -2435,6 +2441,7 @@ When the room ___, raise an objection.`,
2435
2441
  voice_cred_picker_title: "选择一个语音模型",
2436
2442
  voice_cred_switch_confirm: "切换到 {label}?每位 director 的声音会从新模型的清单里重新分配。",
2437
2443
  voice_cred_delete_confirm_active: "删除正在使用的语音凭证?Director 的声音会从下一个可用凭证里重新分配。",
2444
+ voice_cred_delete_confirm: "确认删除这条语音凭证?删除后无法恢复。",
2438
2445
  voice_cred_save_failed: "保存语音凭证失败 — 请检查 key 后重试。",
2439
2446
  ap_voice_empty_no_active: "还没启用语音模型 · 去 Settings 添加一个",
2440
2447
  search_cred_section_title: "▸ 搜索服务",
@@ -2447,6 +2454,7 @@ When the room ___, raise an objection.`,
2447
2454
  search_cred_picker_title: "选择一个搜索服务",
2448
2455
  search_cred_switch_confirm: "切换到 {label}?之后 Web Search 都会走这把凭证。",
2449
2456
  search_cred_delete_confirm_active: "删除正在使用的搜索凭证?Web Search 会切换到下一个可用凭证;如果都没了就停止工作,直到你重新添加。",
2457
+ search_cred_delete_confirm: "确认删除这条搜索凭证?删除后无法恢复。",
2450
2458
  search_cred_save_failed: "保存搜索凭证失败 — 请检查 key 后重试。",
2451
2459
  us_keys_group_skill: "技能服务",
2452
2460
  us_keys_skill_deck: "为需要外部服务的系统技能授权。每位董事可在档案中单独开关。",
@@ -3391,6 +3399,8 @@ When the room ___, raise an objection.`,
3391
3399
  us_active_llm_save_btn: "保存して有効化",
3392
3400
  us_active_llm_save_failed: "保存に失敗しました —— キーを確認して再試行してください。",
3393
3401
  us_active_llm_delete_active_confirm: "現在使用中のプロバイダーを削除します —— 削除後は別の追加済みプロバイダーに切り替わります。他になければシステムは動作しません。続行しますか?",
3402
+ us_llm_delete_confirm: "このLLMクレデンシャルを削除しますか?元には戻せません。",
3403
+ us_provider_key_delete_confirm: "{label} のAPIキーを削除しますか?元には戻せません。",
3394
3404
  us_active_llm_switch_confirm: "現在のプロバイダーを {label} に切り替えますか?全 director が新しいプロバイダーのモデルに再割り当てされます。",
3395
3405
  us_keys_skill_deck: "外部サービスを必要とするシステムスキルを有効化。各エージェントはプロフィール単位で参加可否を選べます。",
3396
3406
  us_ws_backend_tag: "ウェブ検索バックエンド",
@@ -4324,6 +4334,8 @@ When the room ___, raise an objection.`,
4324
4334
  us_active_llm_save_btn: "Guardar y activar",
4325
4335
  us_active_llm_save_failed: "No se pudo guardar la credencial — revisa la clave y vuelve a intentarlo.",
4326
4336
  us_active_llm_delete_active_confirm: "Eliminando el proveedor activo — la sala usará otro proveedor añadido, o dejará de funcionar hasta que añadas uno nuevo. ¿Continuar?",
4337
+ us_llm_delete_confirm: "¿Eliminar esta credencial LLM? No se puede deshacer.",
4338
+ us_provider_key_delete_confirm: "¿Eliminar la clave API de {label}? No se puede deshacer.",
4327
4339
  us_active_llm_switch_confirm: "¿Cambiar el proveedor LLM activo a {label}? Todos los directores se reasignarán a los modelos del nuevo proveedor.",
4328
4340
  us_keys_skill_deck: "habilita habilidades de sistema que necesitan un servicio externo. Cada agente puede optar in/out en su perfil.",
4329
4341
  us_ws_backend_tag: "MOTOR DE BÚSQUEDA WEB",
Binary file