privateboard 0.1.2 → 0.1.4

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.
@@ -31,8 +31,8 @@
31
31
  "Long-form thinking aloud. Directors take the room slowly — pause to think, surface caveats, sit with ambiguity rather than rushing to resolve. Best for novel / ambiguous problems where premature conclusions cost more than slow ones.",
32
32
  sharp:
33
33
  "No hedging. Directors land each turn on a load-bearing claim and back it with the load-bearing reason. They cut the qualifying language (\"perhaps,\" \"could be,\" \"in some cases\") in favour of clear, falsifiable statements. Default for most rooms.",
34
- brutal:
35
- "No prisoners. Directors say what they actually think with zero softening including \"this is wrong\" / \"this won't work because\" without preamble. Trades politeness for signal. Use when you're tired of being agreed with and want the strongest disagreements surfaced fast.",
34
+ terse:
35
+ "Telegraphic. One paragraph, often one sentence. Directors cut every warm-up, every diplomatic packaging, every \"I think\" they state the conclusion and only justify if pressed. NOTE · this is the LENGTH dial, not the harshness dial. Whether a director pushes back hard or builds with you is set by Tone (brainstorm vs critique etc); Terse only decides how long they take saying it.",
36
36
  };
37
37
 
38
38
  /** Generic info popover · single floating element, hover-driven.
@@ -338,9 +338,9 @@
338
338
  <span class="rs-chip-label">Sharp</span>
339
339
  <span class="rs-chip-info rs-info-trigger" data-info-title="Sharp" data-info-body="${escape(INTENSITY_TIPS.sharp)}" tabindex="-1" aria-label="What 'Sharp' means">i</span>
340
340
  </button>
341
- <button type="button" class="rs-chip rs-chip-mini" data-rs-intensity-pick="brutal">
342
- <span class="rs-chip-label">Brutal</span>
343
- <span class="rs-chip-info rs-info-trigger" data-info-title="Brutal" data-info-body="${escape(INTENSITY_TIPS.brutal)}" tabindex="-1" aria-label="What 'Brutal' means">i</span>
341
+ <button type="button" class="rs-chip rs-chip-mini" data-rs-intensity-pick="terse">
342
+ <span class="rs-chip-label">Terse</span>
343
+ <span class="rs-chip-info rs-info-trigger" data-info-title="Terse" data-info-body="${escape(INTENSITY_TIPS.terse)}" tabindex="-1" aria-label="What 'Terse' means">i</span>
344
344
  </button>
345
345
  </div>
346
346
  </div>
@@ -539,7 +539,7 @@
539
539
  }
540
540
 
541
541
  function renderIntensity() {
542
- // Intensity is now a 3-chip row (Calm / Sharp / Brutal) instead of
542
+ // Intensity is now a 3-chip row (Calm / Sharp / Terse) instead of
543
543
  // a slider · highlight the active chip. The hint line above shows
544
544
  // "currently: <value>" so the picked state stays self-evident.
545
545
  const cur = effective("intensity");
@@ -696,7 +696,10 @@
696
696
  renderConfirmState();
697
697
  }
698
698
  function stageIntensity(next) {
699
- if (!["calm", "sharp", "brutal"].includes(next)) return;
699
+ // Accept legacy `brutal` from any code path that still emits it
700
+ // (cached HTML, third-party clients) and normalize to `terse`.
701
+ if (next === "brutal") next = "terse";
702
+ if (!["calm", "sharp", "terse"].includes(next)) return;
700
703
  STAGED.intensity = next === ROOM_STATE.intensity ? null : next;
701
704
  renderIntensity();
702
705
  renderConfirmState();
@@ -1001,7 +1004,7 @@
1001
1004
  const rect = bar.getBoundingClientRect();
1002
1005
  if (rect.width <= 0) return "sharp";
1003
1006
  const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
1004
- return ratio < 0.33 ? "calm" : ratio < 0.67 ? "sharp" : "brutal";
1007
+ return ratio < 0.33 ? "calm" : ratio < 0.67 ? "sharp" : "terse";
1005
1008
  };
1006
1009
  bar.addEventListener("pointerdown", (e) => {
1007
1010
  e.preventDefault();
package/public/themes.css CHANGED
@@ -45,12 +45,25 @@
45
45
  }
46
46
 
47
47
  :root {
48
- --font-human: "Human Sans", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", system-ui, sans-serif;
48
+ /* CJK fallback chain · PingFang SC anchors every stack so Chinese
49
+ glyphs always render as PingFang on macOS rather than falling
50
+ through to whatever `system-ui` resolves to (Songti / Microsoft
51
+ YaHei / Source Han depending on platform). Latin fonts stay
52
+ FIRST so mixed CN/EN prose still picks Human Sans / Inter / Agent
53
+ for English glyphs and only Chinese characters land on PingFang
54
+ via per-glyph fallback. */
55
+ --font-human: "Human Sans", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue",
56
+ "PingFang SC", "PingFang TC", "Hiragino Sans GB", "Microsoft YaHei",
57
+ "Source Han Sans CN", "Noto Sans CJK SC",
58
+ system-ui, sans-serif;
49
59
  /* Agent italic-faced font. ui-sans-serif is a CSS generic keyword
50
60
  (must be unquoted) and resolves to the OS default sans-serif before
51
61
  hitting -apple-system / system-ui — gives a clean italic synth on
52
62
  systems where the bundled "Agent" face fails to load. */
53
- --font-agent: "Agent", ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", system-ui, sans-serif;
63
+ --font-agent: "Agent", ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue",
64
+ "PingFang SC", "PingFang TC", "Hiragino Sans GB", "Microsoft YaHei",
65
+ "Source Han Sans CN", "Noto Sans CJK SC",
66
+ system-ui, sans-serif;
54
67
  /* --sans defaults to the human face — chat input, topic question,
55
68
  replies, and live notes use it unless an agent override applies. */
56
69
  --sans: var(--font-human);
@@ -175,7 +175,7 @@
175
175
  }
176
176
  .us-pane-tag {
177
177
  font-family: var(--mono);
178
- font-size: 10px;
178
+ font-size: 14px;
179
179
  font-weight: 700;
180
180
  letter-spacing: 0.2em;
181
181
  text-transform: uppercase;
@@ -321,6 +321,17 @@
321
321
  letter-spacing: -0.003em;
322
322
  width: 100%;
323
323
  }
324
+ /* API-key masking · the input is `type="text"` so browsers don't pop
325
+ up "Save password?" when the user types a key and navigates away.
326
+ `-webkit-text-security: disc` shows dots instead of the actual
327
+ characters — visually equivalent to a password field, but invisible
328
+ to password managers. Toggle (eye button) just removes this class.
329
+ `text-security` is the standard property name; only Safari + Chrome
330
+ actually implement it under the `-webkit-` prefix today. */
331
+ .us-input-masked {
332
+ -webkit-text-security: disc;
333
+ text-security: disc;
334
+ }
324
335
  .us-input::placeholder { color: var(--text-faint, #3A382F); }
325
336
  /* When a key is on file, we surface a 4+4 masked preview AS the
326
337
  placeholder. Default placeholder colour is the dim "hint" tier,
@@ -394,7 +405,7 @@
394
405
  .us-theme-info { min-width: 0; }
395
406
  .us-theme-name {
396
407
  font-family: var(--mono);
397
- font-size: 12px;
408
+ font-size: 14px;
398
409
  font-weight: 700;
399
410
  color: var(--text, #C8C5BE);
400
411
  letter-spacing: -0.005em;
@@ -442,7 +453,7 @@
442
453
  }
443
454
  .us-key-label {
444
455
  font-family: var(--mono);
445
- font-size: 12px;
456
+ font-size: 14px;
446
457
  font-weight: 700;
447
458
  color: var(--text, #C8C5BE);
448
459
  letter-spacing: -0.003em;
@@ -824,7 +835,7 @@
824
835
  flex: 0 0 auto;
825
836
  }
826
837
  .us-model-name {
827
- font-size: 12.5px;
838
+ font-size: 14px;
828
839
  color: var(--text, #C8C5BE);
829
840
  font-weight: 600;
830
841
  letter-spacing: -0.005em;
@@ -894,7 +905,7 @@
894
905
  .us-agent-row:last-child { border-bottom: none; }
895
906
  .us-agent-name-col { display: inline-flex; align-items: baseline; gap: 8px; min-width: 0; }
896
907
  .us-agent-name {
897
- font-size: 12px;
908
+ font-size: 14px;
898
909
  color: var(--text, #C8C5BE);
899
910
  font-weight: 600;
900
911
  white-space: nowrap;
@@ -1073,7 +1084,7 @@
1073
1084
  .us-models-row:last-child { border-bottom: none; }
1074
1085
  .us-models-name {
1075
1086
  font-family: var(--mono, "Inter", system-ui, sans-serif);
1076
- font-size: 12px;
1087
+ font-size: 14px;
1077
1088
  font-weight: 700;
1078
1089
  color: var(--text, #C8C5BE);
1079
1090
  letter-spacing: -0.005em;
@@ -1148,7 +1159,7 @@
1148
1159
  }
1149
1160
  .us-models-default-name {
1150
1161
  font-family: var(--mono, "Inter", system-ui, sans-serif);
1151
- font-size: 12px;
1162
+ font-size: 14px;
1152
1163
  font-weight: 700;
1153
1164
  color: var(--text, #C8C5BE);
1154
1165
  letter-spacing: -0.005em;
@@ -1233,7 +1244,7 @@
1233
1244
  .us-default-row-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
1234
1245
  .us-default-row-name {
1235
1246
  font-family: var(--mono, "Inter", system-ui, sans-serif);
1236
- font-size: 12px;
1247
+ font-size: 14px;
1237
1248
  font-weight: 700;
1238
1249
  color: var(--text, #C8C5BE);
1239
1250
  letter-spacing: -0.005em;
@@ -156,48 +156,12 @@
156
156
  } catch (e) { /* swallow · UI is optimistic */ }
157
157
  }
158
158
 
159
- /** Pick a provider's "primary" model · used when the user clicks
160
- * "Set as default" on a provider row. We prefer the user's already-
161
- * picked model for that provider (if reachable), otherwise we fall
162
- * back to a curated primary, otherwise the first reachable model
163
- * from that provider in the registry. */
164
- const PRIMARY_BY_PROVIDER = {
165
- anthropic: "opus-4-7",
166
- openai: "gpt-5-5",
167
- google: "gemini-3-flash",
168
- xai: "grok-4-3",
169
- deepseek: "deepseek-v4-pro",
170
- openrouter: "opus-4-7",
171
- };
172
- function primaryModelForProvider(provider) {
173
- const snap = modelsSnapshot();
174
- const reachable = (snap && snap.reachable) || [];
175
- // 1. Curated primary, if reachable.
176
- const curated = PRIMARY_BY_PROVIDER[provider];
177
- if (curated && reachable.find((m) => m.modelV === curated && m.provider === provider)) {
178
- return curated;
179
- }
180
- // 2. First reachable model from that provider.
181
- const first = reachable.find((m) => m.provider === provider);
182
- return first ? first.modelV : null;
183
- }
184
- /** Look up which provider currently owns the saved default model. */
185
- function currentDefaultProvider() {
186
- const snap = modelsSnapshot();
187
- if (!snap || !snap.defaultModelV) return null;
188
- const m = (snap.reachable || []).find((x) => x.modelV === snap.defaultModelV);
189
- return m ? m.provider : null;
190
- }
191
- /** Click handler for the per-row "Set as default" button. Picks the
192
- * provider's primary model + persists it as defaultModelV. The row
193
- * re-renders so the badge moves. */
194
- async function setProviderAsDefault(provider) {
195
- const modelV = primaryModelForProvider(provider);
196
- if (!modelV) return;
197
- await saveDefaultModel(modelV);
198
- // Re-render the keys section so every LLM row updates its badge.
199
- if (currentSection === "keys") renderSection("keys");
200
- }
159
+ // Provider→primary-model helpers (`primaryModelForProvider`,
160
+ // `currentDefaultProvider`, `setProviderAsDefault`) lived here to
161
+ // power the per-row "set as default" button on the API Key pane.
162
+ // That button + its companion bottom-of-pane Default Model picker
163
+ // were removed in favour of the dedicated "Default Model" sidebar
164
+ // pane (single source of truth). Helpers deleted as dead code.
201
165
 
202
166
  // Set / clear a single provider key. The server applies the trim+empty-=delete
203
167
  // semantic; we mirror the resulting meta into our local cache.
@@ -500,35 +464,29 @@
500
464
  const placeholder = has
501
465
  ? (preview || "••••••••")
502
466
  : p.placeholder;
503
- // Default-provider tag · only meaningful for LLM providers.
504
- // We compute it from the saved defaultModelV's owning provider.
505
- // The "Set as default" CTA only appears on LLM rows that are
506
- // configured AND not the current default. Skill rows (Brave) are
507
- // never default-eligible they're search, not a model.
508
- const isLlm = p.group === "llm";
509
- const isDefault = isLlm && currentDefaultProvider() === p.id;
510
- const canSetDefault = isLlm && has && !isDefault && !!primaryModelForProvider(p.id);
511
- const labelExtras = isDefault
512
- ? ' <span class="badge us-key-default-badge">default</span>'
513
- : "";
467
+ // Default-model selection lives entirely in the dedicated
468
+ // "Default Model" sidebar pane. The previous in-row "default"
469
+ // badge + "set as default" button on each LLM provider was a
470
+ // duplicate UX that also competed with the bottom-of-pane
471
+ // "Default model" picker · all three controls did the same
472
+ // thing. The single source of truth is now the sidebar pane.
514
473
  return `
515
- <div class="us-key-row${isDefault ? " is-default" : ""}" data-provider="${p.id}">
474
+ <div class="us-key-row" data-provider="${p.id}">
516
475
  <div class="us-key-head">
517
- <div class="us-key-label">${escape(p.label)}${labelExtras}</div>
476
+ <div class="us-key-label">${escape(p.label)}</div>
518
477
  <div class="us-key-status ${has ? "on" : "off"}" data-status>${has ? "● configured" : "○ not set"}</div>
519
- ${canSetDefault ? `<button type="button" class="us-key-set-default" data-set-default-provider="${p.id}" title="Use ${escape(p.label)} as the default model provider for new agents">set as default</button>` : ""}
520
478
  ${removable ? `<button type="button" class="us-key-remove" data-remove-provider="${p.id}" title="Remove">✕</button>` : ""}
521
479
  </div>
522
480
  <div class="us-key-hint">${escape(p.hint)}</div>
523
481
  <div class="us-input-wrap">
524
482
  <input
525
- type="password"
526
- class="us-input${has ? " has-preview" : ""}"
483
+ type="text"
484
+ class="us-input us-input-masked${has ? " has-preview" : ""}"
527
485
  data-key-input
528
486
  name="bk-${p.id}"
529
487
  placeholder="${escape(placeholder)}"
530
488
  value=""
531
- autocomplete="new-password"
489
+ autocomplete="off"
532
490
  data-lpignore="true"
533
491
  data-1p-ignore="true"
534
492
  data-form-type="other"
@@ -659,43 +617,15 @@
659
617
  `;
660
618
  }).join("");
661
619
 
662
- let defaultBlock = "";
663
- const def = cache.defaultModelV;
664
- if (reachable.length >= 2) {
665
- const optgroups = providers.map((p) => {
666
- const models = byProvider.get(p);
667
- return `<optgroup label="${escape(providerLabel(p))}">${
668
- models.map((m) => `<option value="${escape(m.modelV)}"${m.modelV === def ? " selected" : ""}>${escape(m.displayName)}</option>`).join("")
669
- }</optgroup>`;
670
- }).join("");
671
- defaultBlock = `
672
- <div class="us-models-default">
673
- <div class="us-models-default-label">Default model</div>
674
- <div class="us-models-default-hint">new agents inherit this. when an agent's model goes unreachable, it falls back here too.</div>
675
- <div class="us-input-wrap us-models-default-wrap">
676
- <select class="us-input us-models-default-select" data-default-model>${optgroups}</select>
677
- </div>
678
- </div>
679
- `;
680
- } else {
681
- const m = reachable[0];
682
- defaultBlock = `
683
- <div class="us-models-default">
684
- <div class="us-models-default-label">Default model</div>
685
- <div class="us-models-default-static">
686
- <span class="us-models-default-name">${escape(m.displayName)}</span>
687
- <span class="us-models-default-note">only reachable model</span>
688
- </div>
689
- </div>
690
- `;
691
- }
692
-
620
+ // Default-model selection moved to the sidebar's "Default Model"
621
+ // pane · the previous bottom-of-pane select duplicated that flow.
622
+ // The Available Models block is now read-only (which models are
623
+ // reachable + how they route), nothing else.
693
624
  return `
694
625
  <div class="us-key-group us-key-group-models">
695
626
  <div class="us-key-group-tag">Available models</div>
696
627
  <div class="us-key-group-deck">${reachable.length} model${reachable.length === 1 ? "" : "s"} reachable across ${providers.length} provider${providers.length === 1 ? "" : "s"}. <code>direct</code> uses the provider key, <code>OR</code> routes through OpenRouter.</div>
697
628
  <div class="us-models-list">${blocks}</div>
698
- ${defaultBlock}
699
629
  </div>
700
630
  `;
701
631
  }
@@ -807,61 +737,10 @@
807
737
  const slot = paneEl.querySelector("[data-models-summary]");
808
738
  if (!slot) return;
809
739
  slot.innerHTML = modelsSummaryHTML();
810
- // Also refresh each LLM row's default-state UI · the "set as
811
- // default" button only appears once a model from that provider
812
- // becomes reachable, which happens after the user pastes a key
813
- // and refreshModels() resolves. Walk the rows and patch the
814
- // default-related controls in place so we don't disturb the
815
- // input field the user is typing into.
816
- const defaultProvider = currentDefaultProvider();
817
- paneEl.querySelectorAll(".us-key-row").forEach((row) => {
818
- const id = row.dataset.provider;
819
- const p = PROVIDERS.find((x) => x.id === id);
820
- if (!p || p.group !== "llm") return;
821
- const meta = _keysMeta[id];
822
- const has = !!(meta && meta.configured);
823
- const isDefault = defaultProvider === id;
824
- const canSet = has && !isDefault && !!primaryModelForProvider(id);
825
- // Toggle .is-default on the row.
826
- row.classList.toggle("is-default", isDefault);
827
- // Sync the badge in the label.
828
- const label = row.querySelector(".us-key-label");
829
- if (label) {
830
- const existing = label.querySelector(".us-key-default-badge");
831
- if (isDefault && !existing) {
832
- label.insertAdjacentHTML("beforeend", ' <span class="badge us-key-default-badge">default</span>');
833
- } else if (!isDefault && existing) {
834
- existing.remove();
835
- // Cleanup adjacent whitespace text node so we don't accumulate
836
- // spaces over time.
837
- const next = label.lastChild;
838
- if (next && next.nodeType === 3 && /\s+/.test(next.nodeValue || "")) next.remove();
839
- }
840
- }
841
- // Sync the "set as default" button.
842
- const head = row.querySelector(".us-key-head");
843
- if (head) {
844
- const existing = head.querySelector("[data-set-default-provider]");
845
- if (canSet && !existing) {
846
- // Insert just before the remove button (or at the end).
847
- const btn = document.createElement("button");
848
- btn.type = "button";
849
- btn.className = "us-key-set-default";
850
- btn.dataset.setDefaultProvider = id;
851
- btn.title = `Use ${p.label} as the default model provider for new agents`;
852
- btn.textContent = "set as default";
853
- btn.addEventListener("click", async (e) => {
854
- e.preventDefault();
855
- await setProviderAsDefault(id);
856
- });
857
- const removeBtn = head.querySelector("[data-remove-provider]");
858
- if (removeBtn) head.insertBefore(btn, removeBtn);
859
- else head.appendChild(btn);
860
- } else if (!canSet && existing) {
861
- existing.remove();
862
- }
863
- }
864
- });
740
+ // Default-model state lives in the sidebar's "Default Model"
741
+ // pane · this refresh used to also patch each LLM row's
742
+ // badge / "set as default" button, but those controls were
743
+ // removed to eliminate the duplicate flow.
865
744
  }
866
745
 
867
746
  /* Avatar generation · same flow as the agent profile's regenerate
@@ -1013,11 +892,18 @@
1013
892
  }
1014
893
 
1015
894
  function wireKeysSection() {
895
+ // Show/hide toggle · the input is permanently `type="text"` so
896
+ // browsers don't trigger their "Save password?" popup when the
897
+ // user navigates away from a typed-in key (e.g., clicking another
898
+ // sidebar tab in user prefs). Masking is done via the CSS
899
+ // `-webkit-text-security: disc` rule on `.us-input-masked` —
900
+ // visually identical to a password input but invisible to
901
+ // password managers. Toggle = add/remove the masking class.
1016
902
  paneEl.querySelectorAll("[data-key-eye]").forEach((btn) => {
1017
903
  btn.addEventListener("click", (e) => {
1018
904
  e.preventDefault();
1019
905
  const input = btn.parentElement.querySelector("input");
1020
- if (input) input.type = input.type === "password" ? "text" : "password";
906
+ if (input) input.classList.toggle("us-input-masked");
1021
907
  });
1022
908
  });
1023
909
 
@@ -1043,21 +929,10 @@
1043
929
  });
1044
930
  });
1045
931
 
1046
- // Set this provider as the default · picks the provider's primary
1047
- // model and persists it as defaultModelV. The badge moves to the
1048
- // newly-default row on re-render.
1049
- paneEl.querySelectorAll("[data-set-default-provider]").forEach((btn) => {
1050
- btn.addEventListener("click", async (e) => {
1051
- e.preventDefault();
1052
- const id = btn.dataset.setDefaultProvider;
1053
- await setProviderAsDefault(id);
1054
- });
1055
- });
1056
-
1057
- // Default-model picker · persists to /api/prefs.
1058
- paneEl.querySelectorAll("[data-default-model]").forEach((sel) => {
1059
- sel.addEventListener("change", () => { saveDefaultModel(sel.value); });
1060
- });
932
+ // Default-model controls live in the sidebar's "Default Model"
933
+ // pane only · the previous in-row "set as default" button and
934
+ // the bottom-of-pane Default Model picker were removed because
935
+ // they duplicated that flow.
1061
936
 
1062
937
  // Auto-save: every keystroke / paste persists immediately, no Save button.
1063
938
  // We debounce slightly so we don't fire a server PUT on every character —