privateboard 0.1.11 → 0.1.13

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.
@@ -422,29 +422,107 @@
422
422
  });
423
423
  }
424
424
 
425
- function open() {
425
+ /** Module-scoped overrides set by `open(options)`. Cleared on
426
+ * close so the next default open() starts clean. */
427
+ let _submitOverride = null;
428
+ let _onCancelOverride = null;
429
+ let _classificationOverride = null;
430
+ let _footMetaOverride = null;
431
+ let _createLabelOverride = null;
432
+
433
+ /** Open the overlay. With no args, this is the default Signal
434
+ * manual-config flow (POST /api/agents on Create). With options,
435
+ * callers can pre-fill the form, intercept submit, and tweak
436
+ * the chrome — this lets the Full-persona save flow reuse the
437
+ * exact same overlay instead of forking the UI.
438
+ *
439
+ * options.prefill = { name, bio, instruction, modelV,
440
+ * avatarSeed, avatarPath }
441
+ * options.onSubmit = async (data, helpers) => {}
442
+ * data · { name, handle, bio, instruction, modelV,
443
+ * avatarPath, avatarSeed }
444
+ * helpers · { close }
445
+ * Throw / reject to keep the overlay open and surface an
446
+ * error toast (alert).
447
+ * options.onCancel · fired before close() when the user clicks
448
+ * cancel / X / backdrop · use to e.g. preserve build state.
449
+ * options.classificationLeft · text shown in the upper-left
450
+ * classification bar (default · "DIRECTOR · NEW")
451
+ * options.footMeta · text shown in the foot-meta line
452
+ * options.createLabel · text on the Create button (default ·
453
+ * "Create director")
454
+ */
455
+ function open(options) {
426
456
  if (!overlay) return;
427
- // Reset form to a clean slate every time.
428
- modal.querySelector(".na-name-input").value = "";
429
- modal.querySelector(".na-desc-input").value = "";
430
- modal.querySelector(".na-instr-input").value = "";
431
- avatarState = { placeholder: true, seed: null, roll: 0 };
457
+ options = options || null;
458
+ const prefill = (options && options.prefill) || null;
459
+
460
+ // Reset form. When prefill is supplied, populate after reset.
461
+ modal.querySelector(".na-name-input").value = prefill && prefill.name ? prefill.name : "";
462
+ modal.querySelector(".na-desc-input").value = prefill && prefill.bio ? prefill.bio : "";
463
+ modal.querySelector(".na-instr-input").value = prefill && prefill.instruction ? prefill.instruction : "";
464
+
465
+ // Avatar · pre-seed when caller provides one (e.g. Full-mode
466
+ // build's stashed seed). Falls back to the placeholder otherwise.
467
+ if (prefill && prefill.avatarSeed) {
468
+ avatarState = { placeholder: false, seed: prefill.avatarSeed, roll: 1 };
469
+ } else {
470
+ avatarState = { placeholder: true, seed: null, roll: 0 };
471
+ }
432
472
  paintAvatar();
433
473
  refreshAll();
434
474
 
435
- // Paint the model dropdown's current label · the chip uses the
436
- // same agent-model state as the AI composer (loadAgentComposerModel),
437
- // so re-opening the overlay reflects the user's last pick.
475
+ // Model · push prefill model into the shared agent-composer
476
+ // state so the chip + downstream readers (na-create POST etc.)
477
+ // pick it up. Cleared by the user via the chip dropdown.
478
+ if (prefill && prefill.modelV
479
+ && window.app && typeof window.app.setAgentComposerModel === "function") {
480
+ try { window.app.setAgentComposerModel(prefill.modelV); } catch (_) { /* */ }
481
+ }
438
482
  paintModelLabel();
439
483
 
484
+ // Apply chrome overrides.
485
+ _submitOverride = (options && typeof options.onSubmit === "function") ? options.onSubmit : null;
486
+ _onCancelOverride = (options && typeof options.onCancel === "function") ? options.onCancel : null;
487
+ _classificationOverride = (options && options.classificationLeft) ? String(options.classificationLeft) : null;
488
+ _footMetaOverride = (options && options.footMeta) ? String(options.footMeta) : null;
489
+ _createLabelOverride = (options && options.createLabel) ? String(options.createLabel) : null;
490
+ applyChromeOverrides();
491
+
440
492
  overlay.classList.add("open");
441
493
  overlay.setAttribute("aria-hidden", "false");
442
494
  document.body.style.overflow = "hidden";
443
495
  setTimeout(() => modal.querySelector(".na-name-input").focus(), 80);
444
496
  applyNewAgentI18n();
497
+ // applyChromeOverrides runs AFTER applyNewAgentI18n on i18n
498
+ // events too · the i18n pass would otherwise reset our custom
499
+ // strings.
500
+ applyChromeOverrides();
445
501
  refreshProviderStatus();
446
502
  }
447
503
 
504
+ /** Mirror the override state into the actual DOM. Re-runs after
505
+ * i18n applies so user-facing labels survive locale changes. */
506
+ function applyChromeOverrides() {
507
+ if (!modal) return;
508
+ const classEl = modal.querySelector(".na-classification > span:first-child");
509
+ if (classEl && _classificationOverride) {
510
+ classEl.innerHTML = `<span class="dot">●</span> ${escape(_classificationOverride)}`;
511
+ }
512
+ const footEl = modal.querySelector(".na-foot-meta");
513
+ if (footEl && _footMetaOverride) {
514
+ footEl.textContent = _footMetaOverride;
515
+ footEl.classList.add("na-foot-meta-override");
516
+ } else if (footEl) {
517
+ footEl.classList.remove("na-foot-meta-override");
518
+ }
519
+ const createBtn = modal.querySelector(".na-create");
520
+ if (createBtn && _createLabelOverride) {
521
+ const labelSpan = createBtn.querySelector("span:not(.na-create-mark)");
522
+ if (labelSpan) labelSpan.textContent = _createLabelOverride;
523
+ }
524
+ }
525
+
448
526
  /** Read the user's current agent-model selection (shared with the
449
527
  * AI composer) and paint it into the overlay's `cmp-dd-value` span.
450
528
  * Falls back to a dash when app or its model resolver isn't ready. */
@@ -465,11 +543,25 @@
465
543
  span.textContent = label;
466
544
  }
467
545
 
468
- function close() {
546
+ function close(opts) {
469
547
  if (!overlay) return;
548
+ const fromCancel = !!(opts && opts.fromCancel);
549
+ // Notify the caller (e.g. Full-mode persona flow) that the user
550
+ // dismissed without saving · lets them preserve the build so it
551
+ // can be re-opened. Fired BEFORE we wipe the override state so
552
+ // the callback can still see options it set.
553
+ if (fromCancel && _onCancelOverride) {
554
+ try { _onCancelOverride(); } catch (_) { /* */ }
555
+ }
470
556
  overlay.classList.remove("open");
471
557
  overlay.setAttribute("aria-hidden", "true");
472
558
  document.body.style.overflow = "";
559
+ // Reset overrides so the next default open() starts clean.
560
+ _submitOverride = null;
561
+ _onCancelOverride = null;
562
+ _classificationOverride = null;
563
+ _footMetaOverride = null;
564
+ _createLabelOverride = null;
473
565
  }
474
566
 
475
567
  function slugify(s) {
@@ -628,16 +720,18 @@
628
720
  overlay = document.getElementById("new-agent-overlay");
629
721
  modal = overlay.querySelector(".new-agent-modal");
630
722
 
631
- // Close
632
- modal.querySelector(".na-close").addEventListener("click", close);
633
- modal.querySelector(".na-cancel").addEventListener("click", close);
723
+ // Close · all dismissal paths flag fromCancel:true so the
724
+ // override hook fires (Full-mode persona flow uses it to
725
+ // preserve the build state when the overlay is dismissed).
726
+ modal.querySelector(".na-close").addEventListener("click", () => close({ fromCancel: true }));
727
+ modal.querySelector(".na-cancel").addEventListener("click", () => close({ fromCancel: true }));
634
728
  overlay.addEventListener("click", (e) => {
635
- if (e.target === overlay) close();
729
+ if (e.target === overlay) close({ fromCancel: true });
636
730
  });
637
731
  document.addEventListener("keydown", (e) => {
638
732
  if (e.key === "Escape" && overlay.classList.contains("open")) {
639
733
  e.stopImmediatePropagation();
640
- close();
734
+ close({ fromCancel: true });
641
735
  }
642
736
  });
643
737
 
@@ -702,39 +796,56 @@
702
796
  create.innerHTML = `<span class="na-create-mark">◆</span><span>${escape(t("na_creating"))}</span>`;
703
797
 
704
798
  try {
705
- const res = await fetch("/api/agents", {
706
- method: "POST",
707
- headers: { "content-type": "application/json" },
708
- body: JSON.stringify({ name, bio, instruction, modelV, avatarPath }),
709
- });
710
- if (!res.ok) {
711
- const err = await res.json().catch(() => ({}));
712
- throw new Error(err.error || res.statusText);
713
- }
714
- const created = await res.json();
715
- // Refresh app.agents so the sidebar + agentsById register the
716
- // new director immediately. Falls back gracefully if app isn't
717
- // booted (shouldn't happen in normal flows).
718
- if (window.app && typeof window.app.refreshAgents === "function") {
719
- await window.app.refreshAgents();
799
+ if (_submitOverride) {
800
+ // Caller-supplied submit path · used by the Full-persona
801
+ // save flow to POST to /generate-persona/:jobId/save
802
+ // instead of the default /api/agents. The override
803
+ // owns success-state handling (refresh, close, etc.)
804
+ // but we still close on resolve as a safety.
805
+ await _submitOverride({
806
+ name, bio, instruction, modelV, avatarPath,
807
+ avatarSeed, avatarRoll,
808
+ }, { close, escape, t });
809
+ // Close `fromCancel:false` so the override's onCancel
810
+ // hook doesn't fire (we just saved successfully).
811
+ close();
812
+ } else {
813
+ const res = await fetch("/api/agents", {
814
+ method: "POST",
815
+ headers: { "content-type": "application/json" },
816
+ body: JSON.stringify({ name, bio, instruction, modelV, avatarPath }),
817
+ });
818
+ if (!res.ok) {
819
+ const err = await res.json().catch(() => ({}));
820
+ throw new Error(err.error || res.statusText);
821
+ }
822
+ const created = await res.json();
823
+ // Refresh app.agents so the sidebar + agentsById register the
824
+ // new director immediately. Falls back gracefully if app isn't
825
+ // booted (shouldn't happen in normal flows).
826
+ if (window.app && typeof window.app.refreshAgents === "function") {
827
+ await window.app.refreshAgents();
828
+ }
829
+ // Hand the new agent's id to anyone watching for the event.
830
+ try {
831
+ window.dispatchEvent(new CustomEvent("boardroom:agent-created", { detail: created }));
832
+ } catch (_) { /* */ }
833
+ close();
720
834
  }
721
- // Hand the new agent's id to anyone watching for the event.
722
- try {
723
- window.dispatchEvent(new CustomEvent("boardroom:agent-created", { detail: created }));
724
- } catch (_) { /* */ }
725
- close();
726
835
  } catch (e) {
727
836
  const msg = e && e.message ? e.message : String(e);
728
837
  alert(t("na_create_fail", { msg }));
729
838
  create.disabled = false;
730
839
  create.innerHTML = `<span class="na-create-mark">◆</span><span data-i18n-na="na_create"></span>`;
731
840
  applyNewAgentI18n();
841
+ applyChromeOverrides();
732
842
  }
733
843
  });
734
844
  }
735
845
 
736
- // Public API
737
- window.openNewAgent = function () { if (!overlay) init(); open(); };
846
+ // Public API · options pass-through lets the Full-persona save
847
+ // flow open the same overlay with prefill + custom submit.
848
+ window.openNewAgent = function (options) { if (!overlay) init(); open(options); };
738
849
  window.closeNewAgent = close;
739
850
 
740
851
  if (document.readyState === "loading") {
@@ -87,13 +87,15 @@
87
87
 
88
88
  /* ─── Save toast ─────────────────────────────────────────────
89
89
  Lightweight feedback after a successful POST /api/notes (or a
90
- failure). Bottom-center anchored, fades in/out. Lime for ok,
90
+ failure). Top-center anchored so it doesn't overlap with the
91
+ bottom bar's input controls + voice-replay panel — which were
92
+ covering the previous bottom-center placement. Lime for ok,
91
93
  red-tinted for error. Click to dismiss early. */
92
94
  .qcta-toast {
93
95
  position: fixed;
94
- bottom: 24px;
96
+ top: 72px; /* clears the topbar / classification strip */
95
97
  left: 50%;
96
- transform: translate(-50%, 8px);
98
+ transform: translate(-50%, -8px); /* starts above resting, slides DOWN */
97
99
  z-index: 1600;
98
100
  background: var(--panel, #131312);
99
101
  border: 0.5px solid var(--lime, #6FB572);
@@ -107,6 +109,8 @@
107
109
  opacity: 0;
108
110
  transition: opacity 0.16s ease-out, transform 0.16s ease-out;
109
111
  white-space: nowrap;
112
+ /* Drop shadow flipped so the elevation hint reads as "tray
113
+ dropping from above" rather than "rising from below." */
110
114
  box-shadow: 0 14px 30px -14px rgba(0, 0, 0, 0.55);
111
115
  }
112
116
  .qcta-toast.open {
@@ -494,8 +494,28 @@
494
494
  // some paths but not all; using a self-contained one keeps this
495
495
  // module independent. Lime for ok, red-tinted for error. Auto-
496
496
  // dismisses after 1.8s; click to dismiss early.
497
+ //
498
+ // Horizontal anchor · the toast sits over the CHAT COLUMN, not
499
+ // the viewport center. Centering on the viewport pulls the
500
+ // toast right of the chat (the sidebar eats ~280px on the left)
501
+ // and reads as visually skewed. Recomputed every show so the
502
+ // toast tracks sidebar collapse / window resize.
497
503
  let toastEl = null;
498
504
  let toastTimer = null;
505
+ function positionToast() {
506
+ if (!toastEl) return;
507
+ const chat = document.querySelector(".chat-col") || document.querySelector('[data-main-view="room"]');
508
+ if (!chat) {
509
+ // Fallback · centre on viewport when the chat column isn't
510
+ // mounted (e.g. notes page · toast still useful but anchor
511
+ // missing). Same behaviour as before this fix.
512
+ toastEl.style.left = "50%";
513
+ return;
514
+ }
515
+ const r = chat.getBoundingClientRect();
516
+ const cx = r.left + r.width / 2;
517
+ toastEl.style.left = cx + "px";
518
+ }
499
519
  function toast(msg, kind) {
500
520
  if (!toastEl) {
501
521
  toastEl = document.createElement("div");
@@ -506,6 +526,7 @@
506
526
  toastEl.classList.remove("kind-ok", "kind-error");
507
527
  toastEl.classList.add("kind-" + (kind === "error" ? "error" : "ok"));
508
528
  toastEl.textContent = msg;
529
+ positionToast();
509
530
  toastEl.classList.add("open");
510
531
  if (toastTimer) clearTimeout(toastTimer);
511
532
  toastTimer = setTimeout(() => {
@@ -131,6 +131,11 @@
131
131
  U+FF00-FFEF;
132
132
  }
133
133
 
134
+ /* Print-paper backstop · hidden on screen; shown only during the
135
+ print render. Inside @media print (below) it expands to a
136
+ position:fixed full-page background. */
137
+ [data-print-paper-fill] { display: none; }
138
+
134
139
  @media print {
135
140
  *, *::before, *::after {
136
141
  print-color-adjust: exact !important;
@@ -194,14 +199,36 @@
194
199
 
195
200
  /* Page-frame background · matches body's printed colour per spine
196
201
  so any PDF engine that paints html beyond body's box (some
197
- legacy print pipelines) stays coherent. Modern Chrome with
198
- @page margin: 0 fills the page from body alone, but this is
199
- belt-and-braces. Default white; per-spine `:has()` selectors
200
- below override for the light-paper spines. */
202
+ legacy print pipelines) stays coherent. Default white; per-
203
+ spine selectors below override for the light-paper spines.
204
+ The earlier `:has()` selector silently failed on browsers
205
+ that don't support `:has()` in print mode (some Chrome
206
+ versions, headless / Save-as-PDF pipelines), leaving html
207
+ white and the dark spine PDF unreadable. We now mirror the
208
+ `data-spine` attribute onto `<html>` itself in `swapSpine`
209
+ (see JS below), so a plain attribute selector picks up the
210
+ light-paper colour reliably. */
201
211
  html { background: #FFFFFF !important; }
202
- html:has(body[data-spine="anthropic-essay"]) { background: #F4F0E8 !important; }
203
- html:has(body[data-spine="a16z-thesis"]) { background: #F7F3E8 !important; }
204
- html:has(body[data-spine="boardroom-dark"]) { background: #FAF7F0 !important; }
212
+ html[data-spine="anthropic-essay"] { background: #F4F0E8 !important; }
213
+ html[data-spine="a16z-thesis"] { background: #F7F3E8 !important; }
214
+ html[data-spine="boardroom-dark"] { background: #FAF7F0 !important; }
215
+
216
+ /* Print-paper backstop · a `position: fixed; inset: 0` div with
217
+ `print-color-adjust: exact` reliably paints on every printed
218
+ page in Chrome, where `html { background }` and `body {
219
+ background }` are inconsistently honoured. JS sets the
220
+ background colour per spine in `swapSpine` so this paints the
221
+ cream / white paper colour edge-to-edge. z-index: -1 keeps it
222
+ behind all content. */
223
+ [data-print-paper-fill] {
224
+ display: block !important;
225
+ position: fixed !important;
226
+ inset: 0 !important;
227
+ z-index: -1 !important;
228
+ print-color-adjust: exact !important;
229
+ -webkit-print-color-adjust: exact !important;
230
+ pointer-events: none !important;
231
+ }
205
232
 
206
233
  /* Container resets so .doc fills the printable area instead of
207
234
  being clipped at its 880px on-screen max-width. */
@@ -2822,6 +2849,16 @@
2822
2849
  </head>
2823
2850
  <body>
2824
2851
 
2852
+ <!-- Print-only paper background · a position:fixed div that fills the
2853
+ entire page on every printed sheet. Chrome's print pipeline is
2854
+ historically unreliable about painting `html { background }` and
2855
+ `body { background }` (even with print-color-adjust: exact),
2856
+ causing dark-spine reports to save as PDFs with the wrong (white)
2857
+ bleed colour. A real DIV with print-color-adjust paints reliably
2858
+ on every page. JS sets the background colour per spine in
2859
+ `swapSpine`. Hidden on screen. -->
2860
+ <div class="print-paper-fill" data-print-paper-fill aria-hidden="true"></div>
2861
+
2825
2862
  <div class="top-rule">
2826
2863
  <span class="crumb">Boardroom <span class="accent">·</span> Insights</span>
2827
2864
  <div class="top-actions">
@@ -2897,6 +2934,15 @@
2897
2934
  const href = `report/spines/${safe}.css`;
2898
2935
  if (link.getAttribute("href") !== href) link.setAttribute("href", href);
2899
2936
  document.body.setAttribute("data-spine", safe);
2937
+ // Mirror the spine onto <html> too so the @media print rule
2938
+ // `html[data-spine="..."] { background: ... }` resolves
2939
+ // reliably in every Chrome / Save-as-PDF pipeline. The
2940
+ // earlier `:has()`-based selector silently failed in some
2941
+ // print pipelines, leaving html white and the dark-spine PDF
2942
+ // showing wrong text colours against an unintended white
2943
+ // page bleed. Setting data-spine on the root element bypasses
2944
+ // `:has()` entirely.
2945
+ document.documentElement.setAttribute("data-spine", safe);
2900
2946
  // Mark the document as CJK when the brief is in Chinese (or any
2901
2947
  // CJK script). Several spines — anthropic-essay, a16z-thesis,
2902
2948
  // boardroom-dark in the bottom-line callout — use `font-style:
@@ -2909,10 +2955,27 @@
2909
2955
  const sample = ((brief && brief.title) || "") + " " + ((brief && brief.bodyMd) || "");
2910
2956
  const isCjk = /[㐀-鿿぀-ヿ가-힯]/.test(sample);
2911
2957
  document.body.classList.toggle("is-cjk", isCjk);
2912
- // Inject (or replace) the per-spine @page background rule so the
2913
- // PDF page bleed matches the paper colour. Replaces any prior
2914
- // injected rule for this same id, so swapping spines repaints
2915
- // cleanly without stacking style nodes.
2958
+ // Inject (or replace) the per-spine print-paper rule. We paint
2959
+ // the cream / white paper colour at THREE levels because Chrome
2960
+ // / Save-as-PDF pipelines silently drop one or another:
2961
+ // · `@page { background }` — paints the @page bleed
2962
+ // (technically the canonical surface, but historically buggy
2963
+ // and ignored by some print pipelines)
2964
+ // · `html { background }` — paints the document canvas;
2965
+ // overridden by the static `html { background: #FFFFFF }`
2966
+ // default in @media print, but THIS injected rule wins on
2967
+ // specificity (it loads after the static stylesheet)
2968
+ // · `body { background }` — paints the body's content box;
2969
+ // the most reliable surface but only fills inside @page
2970
+ // margins
2971
+ // With all three forced to the same paper colour AND
2972
+ // print-color-adjust: exact !important, the saved PDF reliably
2973
+ // shows the spine's paper colour edge-to-edge regardless of
2974
+ // which surface Chrome ends up honouring on a given OS / Chrome
2975
+ // version. The earlier single-`@page`-rule injection was the
2976
+ // root cause of the "dark report → white PDF" bug — Chrome
2977
+ // dropped the @page background and the static `html {
2978
+ // background: #FFFFFF }` default won.
2916
2979
  const paper = SPINE_PAPER[safe] || "#FFFFFF";
2917
2980
  let pageStyle = document.getElementById("spine-page-bg");
2918
2981
  if (!pageStyle) {
@@ -2920,7 +2983,24 @@
2920
2983
  pageStyle.id = "spine-page-bg";
2921
2984
  document.head.appendChild(pageStyle);
2922
2985
  }
2923
- pageStyle.textContent = `@media print { @page { background: ${paper}; } }`;
2986
+ pageStyle.textContent = `
2987
+ @media print {
2988
+ @page { background: ${paper}; }
2989
+ html, body {
2990
+ background: ${paper} !important;
2991
+ print-color-adjust: exact !important;
2992
+ -webkit-print-color-adjust: exact !important;
2993
+ }
2994
+ }
2995
+ `;
2996
+ // Print-paper backstop · the position:fixed div in <body> needs
2997
+ // its background colour set directly (inline style) so it paints
2998
+ // the right paper colour on every printed page. This is the
2999
+ // FALLBACK surface — Chrome's print pipeline drops `html` and
3000
+ // `body` backgrounds inconsistently, but always paints a real
3001
+ // div with print-color-adjust: exact.
3002
+ const fill = document.querySelector("[data-print-paper-fill]");
3003
+ if (fill) fill.style.background = paper;
2924
3004
  }
2925
3005
 
2926
3006
  function escape(s) {
@@ -3400,6 +3480,23 @@
3400
3480
  // bullets, ordered lists, paragraphs, blockquotes, fenced code blocks
3401
3481
  // (incl. ```mermaid for diagrams), and pipe tables.
3402
3482
  function renderMarkdown(md) {
3483
+ // Pre-pre-pass · normalise fenced-block openers that the LLM
3484
+ // sometimes glues onto the tail of a paragraph (`...end of prose.```views-compared`).
3485
+ // The fence-recognition regex below requires the opener to be on
3486
+ // its OWN line (`/^```.../`), so a mid-line opener falls through
3487
+ // and the fenced body renders as raw text full of `{` / `}` / `:`
3488
+ // / quote symbols — exactly the "符号导致内容混乱" report we
3489
+ // get when the brief writer writes the closing prose without a
3490
+ // newline before the dispatch fence. Insert a blank line in
3491
+ // front of every fence opener that isn't already at the start
3492
+ // of a line. Keep paired closing fences alone (they're always
3493
+ // preceded by content inside the block — that's fine).
3494
+ // Match openers only · `[\w-]+` requires a non-empty lang tag so
3495
+ // bare closing fences (`\`\`\`\n`) don't get rewritten too. Without
3496
+ // that distinction, every closing fence also picks up an extra
3497
+ // blank line and the body slice includes a trailing empty line.
3498
+ md = String(md || "").replace(/([^\n])(\n?)```([\w-]+)\s*\n/g,
3499
+ (_m, before, _newline, lang) => `${before}\n\n\`\`\`${lang}\n`);
3403
3500
  // Pre-pass · pull out fenced ```code blocks before splitting on blank
3404
3501
  // lines, since a code block can legally contain blank lines.
3405
3502
  const placeholders = [];
@@ -472,6 +472,73 @@
472
472
  color: var(--lime, #6FB572);
473
473
  }
474
474
 
475
+ /* Vote-trigger radio group · two cells per row (Auto / Manual)
476
+ with the radio glyph + name pinned to the top line and a single
477
+ compact description line beneath. The previous stacked-column
478
+ layout wasted vertical space for two short binary options; the
479
+ 2-up grid sits the choices side-by-side at content height so the
480
+ panel stays dense. The section's own .rs-config-row-hint above
481
+ carries the "when the chair opens the round-end vote" framing,
482
+ so each cell only needs its own short rationale. */
483
+ .rs-vote-trigger-grp {
484
+ display: grid;
485
+ grid-template-columns: 1fr 1fr;
486
+ gap: 6px;
487
+ }
488
+ .rs-radio-row {
489
+ display: grid;
490
+ grid-template-columns: 13px 1fr;
491
+ align-items: center;
492
+ column-gap: 8px;
493
+ row-gap: 2px;
494
+ padding: 7px 9px;
495
+ background: var(--bg, #0A0A0A);
496
+ border: 0.5px solid var(--line-bright, #2A2A26);
497
+ cursor: pointer;
498
+ transition: border-color 0.12s, background 0.12s;
499
+ }
500
+ .rs-radio-row:hover { border-color: var(--text-faint, #3A382F); }
501
+ .rs-radio-row input[type="radio"] {
502
+ grid-row: 1;
503
+ grid-column: 1;
504
+ margin: 0;
505
+ accent-color: var(--lime, #6FB572);
506
+ cursor: pointer;
507
+ width: 13px;
508
+ height: 13px;
509
+ flex-shrink: 0;
510
+ }
511
+ .rs-radio-label {
512
+ grid-row: 1 / span 2;
513
+ grid-column: 2;
514
+ display: flex;
515
+ flex-direction: column;
516
+ gap: 1px;
517
+ min-width: 0;
518
+ }
519
+ .rs-radio-name {
520
+ font-family: var(--font-human);
521
+ font-size: 12px;
522
+ font-weight: 600;
523
+ color: var(--text-soft, #8E8B83);
524
+ letter-spacing: -0.005em;
525
+ line-height: 1.2;
526
+ }
527
+ .rs-radio-desc {
528
+ font-family: var(--font-human);
529
+ font-size: 11px;
530
+ color: var(--text-faint, #5C5A4D);
531
+ letter-spacing: -0.003em;
532
+ line-height: 1.35;
533
+ }
534
+ .rs-radio-row.active {
535
+ border-color: var(--lime, #6FB572);
536
+ background: var(--panel-2, #1A1A18);
537
+ }
538
+ .rs-radio-row.active .rs-radio-name {
539
+ color: var(--lime, #6FB572);
540
+ }
541
+
475
542
  /* Section label · matches the new composer's mono micro-type kicker.
476
543
  No leading lime bullet — the inline count chip carries enough
477
544
  visual weight, and dropping the bullet lets the labels sit flush