privateboard 0.1.0 → 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.
@@ -183,13 +183,15 @@
183
183
  .rs-config-row { grid-template-columns: 1fr; gap: 6px; }
184
184
  }
185
185
 
186
- /* Tone + intensity chip rows · explicit grid columns (4 / 3) so the
186
+ /* Tone + intensity chip rows · explicit grid columns (5 / 3) so the
187
187
  chips always sit in a single row at content width. flex-wrap was
188
188
  the prior approach but allowed chips to fold onto a second line on
189
- tight viewports — the user wants them locked to one row. */
189
+ tight viewports — the user wants them locked to one row. Tone count
190
+ bumped from 4 → 5 when `research` and `critique` were added; if
191
+ you add more modes, bump this number too. */
190
192
  .rs-mode-grid {
191
193
  display: grid;
192
- grid-template-columns: repeat(4, max-content);
194
+ grid-template-columns: repeat(5, max-content);
193
195
  gap: 3px;
194
196
  }
195
197
  .rs-intensity-chips {
@@ -439,7 +441,7 @@
439
441
  /* Section label · matches the new composer's mono micro-type kicker.
440
442
  No leading lime bullet — the inline count chip carries enough
441
443
  visual weight, and dropping the bullet lets the labels sit flush
442
- left like the rest of the prototype's section heads. */
444
+ left like the rest of the app's section heads. */
443
445
  .rs-label {
444
446
  font-size: 9px;
445
447
  font-weight: 700;
@@ -16,10 +16,12 @@
16
16
  "Co-creator. Directors stand with you and push the idea outward — yes-and a contribution, name a concrete adjacent variant (\"what if we instead…\"), borrow pieces from another director's turn into new combinations. May end with one curious question, never a defense-demanding one.",
17
17
  constructive:
18
18
  "Sympathetic interrogator. They want you to win, but only via the strongest version. Each turn picks ONE load-bearing assumption and proposes the candidate stronger version that would stand. Disagreement is allowed, but every objection comes packaged with a forward path.",
19
+ research:
20
+ "Collaborative inquiry. The room mines the materials in front of it (your brief, web-search results, prior turns) for what's actually there. Each turn must cite a specific source piece, label it OBSERVATION / INFERENCE / SPECULATION, then extract the insight your lens makes salient. Defaults web search ON when a Brave key is configured.",
19
21
  debate:
20
22
  "Peer reviewer. Each turn opens by steelmanning your strongest claim (\"the strongest read of your point is…\") and only then attacks THAT version — naming a specific risk, demanding evidence, exposing the trade-off you're hiding. Sharp but professional. Skipping the steelman is a protocol violation.",
21
- "no-mercy":
22
- "Hostile reviewer. Default: you're wrong until proved otherwise. Points at vague terms / hand-waved mechanisms, says \"this is wrong because X\" flat no hedge. Refuses undefined terms. Attacks the argument as half-baked / wrong, never the person. Forbidden hedge words: perhaps / maybe / could be / might.",
23
+ critique:
24
+ "Review board. The room audits a finished deliverable systematically each turn names the dimension being audited (logic / evidence / scope / risk / etc.), surfaces 2–3 specific flaws labelled BLOCKER · MAJOR · MINOR, points at the load-bearing piece, and indicates the direction a fix would lie. At least one BLOCKER or MAJOR per turn is mandatory.",
23
25
  };
24
26
 
25
27
  /** Intensity tooltips · what each pick does to the directors' default
@@ -29,8 +31,8 @@
29
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.",
30
32
  sharp:
31
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.",
32
- brutal:
33
- "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.",
34
36
  };
35
37
 
36
38
  /** Generic info popover · single floating element, hover-driven.
@@ -146,8 +148,8 @@
146
148
  const NAMES = {};
147
149
 
148
150
  // Baseline state — synced from window.app.currentRoom each time the
149
- // overlay opens. The fallback values keep the prototype usable in
150
- // standalone preview (where window.app is absent).
151
+ // overlay opens. The fallback values keep the page usable in standalone
152
+ // preview (where window.app is absent).
151
153
  const ROOM_STATE = {
152
154
  title: "the minimum viable structure of a data flywheel",
153
155
  topic: "I want to build an AI assistant for enterprise HR teams — automated resume screening + interview guides. Does this idea hold up under three-director scrutiny?",
@@ -201,8 +203,9 @@
201
203
  const MODES = [
202
204
  { v: "brainstorm", label: "Brainstorm", desc: "yes-and" },
203
205
  { v: "constructive", label: "Constructive", desc: "push & sharpen" },
206
+ { v: "research", label: "Research", desc: "mine the material" },
204
207
  { v: "debate", label: "Debate", desc: "find holes" },
205
- { v: "no-mercy", label: "No Mercy", desc: "tear apart" }
208
+ { v: "critique", label: "Critique", desc: "audit the deliverable" }
206
209
  ];
207
210
 
208
211
 
@@ -335,9 +338,9 @@
335
338
  <span class="rs-chip-label">Sharp</span>
336
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>
337
340
  </button>
338
- <button type="button" class="rs-chip rs-chip-mini" data-rs-intensity-pick="brutal">
339
- <span class="rs-chip-label">Brutal</span>
340
- <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>
341
344
  </button>
342
345
  </div>
343
346
  </div>
@@ -536,7 +539,7 @@
536
539
  }
537
540
 
538
541
  function renderIntensity() {
539
- // 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
540
543
  // a slider · highlight the active chip. The hint line above shows
541
544
  // "currently: <value>" so the picked state stays self-evident.
542
545
  const cur = effective("intensity");
@@ -693,7 +696,10 @@
693
696
  renderConfirmState();
694
697
  }
695
698
  function stageIntensity(next) {
696
- 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;
697
703
  STAGED.intensity = next === ROOM_STATE.intensity ? null : next;
698
704
  renderIntensity();
699
705
  renderConfirmState();
@@ -998,7 +1004,7 @@
998
1004
  const rect = bar.getBoundingClientRect();
999
1005
  if (rect.width <= 0) return "sharp";
1000
1006
  const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
1001
- return ratio < 0.33 ? "calm" : ratio < 0.67 ? "sharp" : "brutal";
1007
+ return ratio < 0.33 ? "calm" : ratio < 0.67 ? "sharp" : "terse";
1002
1008
  };
1003
1009
  bar.addEventListener("pointerdown", (e) => {
1004
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;
@@ -656,6 +667,24 @@
656
667
  }
657
668
  .us-foot .us-done:hover { border-color: var(--lime, #6FB572); color: var(--lime, #6FB572); }
658
669
 
670
+ /* Right-side cluster · website link + Done button. */
671
+ .us-foot-right {
672
+ display: flex;
673
+ align-items: center;
674
+ gap: 14px;
675
+ }
676
+ .us-foot .us-website {
677
+ font-family: var(--mono);
678
+ font-size: 9.5px;
679
+ font-weight: 600;
680
+ letter-spacing: 0.1em;
681
+ text-transform: uppercase;
682
+ color: var(--text-soft, #8E8B83);
683
+ text-decoration: none;
684
+ transition: color 0.12s;
685
+ }
686
+ .us-foot .us-website:hover { color: var(--lime, #6FB572); }
687
+
659
688
  @media (max-width: 600px) {
660
689
  .user-settings-overlay { padding: 12px; }
661
690
  .us-head { padding-left: 14px; padding-right: 14px; }
@@ -806,7 +835,7 @@
806
835
  flex: 0 0 auto;
807
836
  }
808
837
  .us-model-name {
809
- font-size: 12.5px;
838
+ font-size: 14px;
810
839
  color: var(--text, #C8C5BE);
811
840
  font-weight: 600;
812
841
  letter-spacing: -0.005em;
@@ -876,7 +905,7 @@
876
905
  .us-agent-row:last-child { border-bottom: none; }
877
906
  .us-agent-name-col { display: inline-flex; align-items: baseline; gap: 8px; min-width: 0; }
878
907
  .us-agent-name {
879
- font-size: 12px;
908
+ font-size: 14px;
880
909
  color: var(--text, #C8C5BE);
881
910
  font-weight: 600;
882
911
  white-space: nowrap;
@@ -1055,7 +1084,7 @@
1055
1084
  .us-models-row:last-child { border-bottom: none; }
1056
1085
  .us-models-name {
1057
1086
  font-family: var(--mono, "Inter", system-ui, sans-serif);
1058
- font-size: 12px;
1087
+ font-size: 14px;
1059
1088
  font-weight: 700;
1060
1089
  color: var(--text, #C8C5BE);
1061
1090
  letter-spacing: -0.005em;
@@ -1130,7 +1159,7 @@
1130
1159
  }
1131
1160
  .us-models-default-name {
1132
1161
  font-family: var(--mono, "Inter", system-ui, sans-serif);
1133
- font-size: 12px;
1162
+ font-size: 14px;
1134
1163
  font-weight: 700;
1135
1164
  color: var(--text, #C8C5BE);
1136
1165
  letter-spacing: -0.005em;
@@ -1215,7 +1244,7 @@
1215
1244
  .us-default-row-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
1216
1245
  .us-default-row-name {
1217
1246
  font-family: var(--mono, "Inter", system-ui, sans-serif);
1218
- font-size: 12px;
1247
+ font-size: 14px;
1219
1248
  font-weight: 700;
1220
1249
  color: var(--text, #C8C5BE);
1221
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
@@ -903,7 +782,10 @@
903
782
 
904
783
  <footer class="us-foot">
905
784
  <span class="saved">changes save automatically</span>
906
- <button type="button" class="us-done">[ Done ]</button>
785
+ <div class="us-foot-right">
786
+ <a class="us-website" href="/home.html" target="_blank" rel="noopener">website ↗</a>
787
+ <button type="button" class="us-done">[ Done ]</button>
788
+ </div>
907
789
  </footer>
908
790
 
909
791
  </div>
@@ -971,7 +853,6 @@
971
853
  if (typeof window.app.renderUserBlock === "function") window.app.renderUserBlock();
972
854
  } else {
973
855
  document.querySelectorAll(".sidebar-foot .user-name").forEach((el) => { el.textContent = (u.name || "Kay").toUpperCase(); });
974
- document.querySelectorAll(".sidebar-foot .user-menu .name").forEach((el) => { el.textContent = u.name || "Kay"; });
975
856
  }
976
857
  }
977
858
 
@@ -1011,11 +892,18 @@
1011
892
  }
1012
893
 
1013
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.
1014
902
  paneEl.querySelectorAll("[data-key-eye]").forEach((btn) => {
1015
903
  btn.addEventListener("click", (e) => {
1016
904
  e.preventDefault();
1017
905
  const input = btn.parentElement.querySelector("input");
1018
- if (input) input.type = input.type === "password" ? "text" : "password";
906
+ if (input) input.classList.toggle("us-input-masked");
1019
907
  });
1020
908
  });
1021
909
 
@@ -1041,21 +929,10 @@
1041
929
  });
1042
930
  });
1043
931
 
1044
- // Set this provider as the default · picks the provider's primary
1045
- // model and persists it as defaultModelV. The badge moves to the
1046
- // newly-default row on re-render.
1047
- paneEl.querySelectorAll("[data-set-default-provider]").forEach((btn) => {
1048
- btn.addEventListener("click", async (e) => {
1049
- e.preventDefault();
1050
- const id = btn.dataset.setDefaultProvider;
1051
- await setProviderAsDefault(id);
1052
- });
1053
- });
1054
-
1055
- // Default-model picker · persists to /api/prefs.
1056
- paneEl.querySelectorAll("[data-default-model]").forEach((sel) => {
1057
- sel.addEventListener("change", () => { saveDefaultModel(sel.value); });
1058
- });
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.
1059
936
 
1060
937
  // Auto-save: every keystroke / paste persists immediately, no Save button.
1061
938
  // We debounce slightly so we don't fire a server PUT on every character —
@@ -1124,6 +1001,26 @@
1124
1001
  // wireKeysSection again.
1125
1002
  fetchKeyMeta().then(() => {
1126
1003
  if (currentSection !== "keys") return;
1004
+
1005
+ // After-onboarding sync · the bootstrap fetchKeyMeta ran before
1006
+ // the user wrote their first key (during onboarding). When the
1007
+ // user opens settings without a page refresh, _keysMeta was
1008
+ // empty at first render, so activeProviders was derived without
1009
+ // the just-configured provider — and the keys tab paints with
1010
+ // no row for it (e.g. "no OpenRouter section visible until
1011
+ // refresh"). Detect that drift here and rebuild the section
1012
+ // when a configured provider is missing its row. Inline pill
1013
+ // refresh below handles the simpler case where the row already
1014
+ // exists and only its `● configured` state needs flipping.
1015
+ const missingActive = LLM_PROVIDER_IDS.filter(
1016
+ (id) => _keysMeta[id] && _keysMeta[id].configured && !activeProviders.includes(id),
1017
+ );
1018
+ if (missingActive.length > 0) {
1019
+ activeProviders = null;
1020
+ rerenderKeysSection();
1021
+ return;
1022
+ }
1023
+
1127
1024
  paneEl.querySelectorAll(".us-key-row").forEach((row) => {
1128
1025
  const provider = row.dataset.provider;
1129
1026
  const meta = _keysMeta[provider];
@@ -1179,6 +1076,13 @@
1179
1076
  if (window.app && typeof window.app.refreshKeys === "function") {
1180
1077
  window.app.refreshKeys();
1181
1078
  }
1079
+ // If an agent profile is open, its skill rows have data-key-
1080
+ // configured cached from first paint. Re-fetch so the web-search
1081
+ // toggle no longer shows the "configure key" prompt after the
1082
+ // user added the Brave key here.
1083
+ if (typeof window.refreshAgentProfileSkills === "function") {
1084
+ window.refreshAgentProfileSkills();
1085
+ }
1182
1086
  }
1183
1087
 
1184
1088
  function init() {