privateboard 0.1.0

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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +120 -0
  3. package/dist/cli.js +10502 -0
  4. package/dist/cli.js.map +1 -0
  5. package/package.json +63 -0
  6. package/public/adjourn-overlay.css +253 -0
  7. package/public/agent-overlay.css +444 -0
  8. package/public/agent-overlay.js +604 -0
  9. package/public/agent-profile.css +3230 -0
  10. package/public/agent-profile.js +3329 -0
  11. package/public/app.js +6629 -0
  12. package/public/auto-hide-scroll.js +90 -0
  13. package/public/avatar-skill.js +793 -0
  14. package/public/avatars/chair.svg +98 -0
  15. package/public/avatars/first-principles.svg +122 -0
  16. package/public/avatars/long-horizon.svg +147 -0
  17. package/public/avatars/open_ai.png +0 -0
  18. package/public/avatars/phenomenologist.svg +130 -0
  19. package/public/avatars/socrates.svg +187 -0
  20. package/public/avatars/user-empathy.svg +117 -0
  21. package/public/avatars/value-investor.svg +117 -0
  22. package/public/favicon.svg +10 -0
  23. package/public/fonts/agent-Italic.woff2 +0 -0
  24. package/public/fonts/human-sans.woff2 +0 -0
  25. package/public/icons.css +103 -0
  26. package/public/models-cache.js +57 -0
  27. package/public/new-agent.css +1359 -0
  28. package/public/new-agent.js +675 -0
  29. package/public/onboarding.css +628 -0
  30. package/public/onboarding.js +782 -0
  31. package/public/prototype-dashboard.html +7596 -0
  32. package/public/report/spines/a16z-thesis.css +1055 -0
  33. package/public/report/spines/anthropic-essay.css +556 -0
  34. package/public/report/spines/boardroom-dark.css +1082 -0
  35. package/public/report/spines/gartner-note.css +538 -0
  36. package/public/report/spines/mckinsey-deck.css +523 -0
  37. package/public/report/spines/openai-paper.css +516 -0
  38. package/public/report.html +1417 -0
  39. package/public/room-settings.css +895 -0
  40. package/public/room-settings.js +1039 -0
  41. package/public/themes.css +338 -0
  42. package/public/user-settings.css +1236 -0
  43. package/public/user-settings.js +1291 -0
@@ -0,0 +1,1039 @@
1
+ /* ═══════════════════════════════════════════
2
+ ROOM SETTINGS OVERLAY
3
+ ═══════════════════════════════════════════
4
+ - Members section shows EVERY available director inline so the user
5
+ can scroll the list and toggle add/remove in place. The old
6
+ agent-picker sub-overlay was removed at the user's request.
7
+ - Two-click confirm on member removal.
8
+ - Every config change is logged to ROOM_STATE.history, surfaced in
9
+ a "History" section inside the modal, AND emitted as a divider
10
+ strip in the chat transcript.
11
+ */
12
+ (function () {
13
+ /** Tone tooltips · keep in lockstep with app.js. */
14
+ const TONE_TIPS = {
15
+ brainstorm:
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
+ constructive:
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
+ debate:
20
+ "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
+ };
24
+
25
+ /** Intensity tooltips · what each pick does to the directors' default
26
+ * speaking register. Surfaced via the per-chip info icon. */
27
+ const INTENSITY_TIPS = {
28
+ calm:
29
+ "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
+ sharp:
31
+ "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
+ };
35
+
36
+ /** Generic info popover · single floating element, hover-driven.
37
+ * Reads the tip text from the trigger's `data-info-body` attribute
38
+ * and the title from `data-info-title`. Dismissed on mouseleave
39
+ * (with a small grace window) or Esc. Replaces the tone-only
40
+ * popover with a kind-agnostic one used by both tone + intensity. */
41
+ let infoPopHideTimer = null;
42
+ function openInfoPopover(triggerEl) {
43
+ if (infoPopHideTimer) { clearTimeout(infoPopHideTimer); infoPopHideTimer = null; }
44
+ const title = triggerEl.getAttribute("data-info-title") || "";
45
+ const body = triggerEl.getAttribute("data-info-body") || "";
46
+ if (!body) return;
47
+ let pop = document.getElementById("rs-info-popover");
48
+ if (!pop) {
49
+ pop = document.createElement("div");
50
+ pop.id = "rs-info-popover";
51
+ pop.className = "rs-info-popover";
52
+ document.body.appendChild(pop);
53
+ // Stay open while the cursor is on the popover itself.
54
+ pop.addEventListener("mouseenter", () => {
55
+ if (infoPopHideTimer) { clearTimeout(infoPopHideTimer); infoPopHideTimer = null; }
56
+ });
57
+ pop.addEventListener("mouseleave", () => scheduleClosePopover());
58
+ }
59
+ pop.innerHTML = `
60
+ ${title ? `<div class="rs-info-popover-head">${escape(title)}</div>` : ""}
61
+ <div class="rs-info-popover-body">${escape(body)}</div>
62
+ `;
63
+ const r = triggerEl.getBoundingClientRect();
64
+ const popH = pop.offsetHeight;
65
+ const popW = pop.offsetWidth;
66
+ let top = r.bottom + 6;
67
+ if (top + popH > window.innerHeight - 12) top = r.top - popH - 6;
68
+ let left = r.left + r.width / 2 - popW / 2;
69
+ if (left + popW > window.innerWidth - 12) left = window.innerWidth - popW - 12;
70
+ if (left < 12) left = 12;
71
+ pop.style.top = `${Math.round(top)}px`;
72
+ pop.style.left = `${Math.round(left)}px`;
73
+ }
74
+ function scheduleClosePopover() {
75
+ if (infoPopHideTimer) clearTimeout(infoPopHideTimer);
76
+ infoPopHideTimer = setTimeout(closeInfoPopover, 80);
77
+ }
78
+ function closeInfoPopover() {
79
+ const el = document.getElementById("rs-info-popover");
80
+ if (el) el.remove();
81
+ if (infoPopHideTimer) { clearTimeout(infoPopHideTimer); infoPopHideTimer = null; }
82
+ }
83
+ document.addEventListener("keydown", (e) => {
84
+ if (e.key === "Escape") closeInfoPopover();
85
+ });
86
+
87
+ // Standalone-preview fallback · used only when window.app.agents
88
+ // isn't available (e.g. opening this file directly in the browser
89
+ // without the boardroom server). Live data takes precedence.
90
+ const FALLBACK_DIRECTORS = [
91
+ { slug: "socrates", name: "Socrates", role: "Skeptic" },
92
+ { slug: "first-principles", name: "First Principles", role: "Causal Reasoning" },
93
+ { slug: "value-investor", name: "Value Investor", role: "Pattern Recognition" },
94
+ { slug: "user-empathy", name: "User-Empathy", role: "Empathy Lens" },
95
+ { slug: "long-horizon", name: "Long Horizon", role: "Historical Lens" },
96
+ { slug: "phenomenologist", name: "Phenomenologist", role: "Intern · trial" }
97
+ ];
98
+
99
+ /** Return the full director catalog — live agents from window.app
100
+ * when present, otherwise the standalone fallback. Each entry has
101
+ * { slug, name, role, avatar } where avatar is the agent's stored
102
+ * avatarPath (data: URL for custom agents, /avatars/*.svg for seeds). */
103
+ function getAvailableAgents() {
104
+ const live = (window.app && Array.isArray(window.app.agents)) ? window.app.agents : null;
105
+ if (live && live.length > 0) {
106
+ return live
107
+ .filter((a) => a.roleKind === "director")
108
+ .map((a) => ({
109
+ slug: a.id,
110
+ name: a.name,
111
+ role: a.roleTag || "Director",
112
+ avatar: a.avatarPath || `avatars/${a.id}.svg`,
113
+ modelV: a.modelV || "",
114
+ }));
115
+ }
116
+ return FALLBACK_DIRECTORS.map((d) => ({ ...d, avatar: `avatars/${d.slug}.svg`, modelV: "" }));
117
+ }
118
+
119
+ /** Friendly labels for the modelV strings · same shape as the
120
+ * composer-pick row's model badge. Falls back to the raw modelV
121
+ * string when the agent's model isn't in the table (e.g. a brand-
122
+ * new model the catalog doesn't know about yet). */
123
+ const MODEL_LABELS = {
124
+ "sonnet-4-6": "Sonnet 4.6",
125
+ "opus-4-7": "Opus 4.7",
126
+ "haiku-4-5": "Haiku 4.5",
127
+ "gpt-5-5": "GPT-5.5",
128
+ "gpt-5-4": "GPT-5.4",
129
+ "gpt-5-4-mini": "GPT-5.4 Mini",
130
+ "gpt-5-5-pro": "GPT-5.5 Pro",
131
+ "codex-5-4": "ChatGPT Codex 5.4",
132
+ "gemini-3-1": "Gemini 3.1 Pro",
133
+ "gemini-3-flash": "Gemini 3 Flash",
134
+ "gemini-3-1-flash": "Gemini 3.1 Flash Lite",
135
+ "grok-4-3": "Grok 4.3",
136
+ "grok-4-1-fast": "Grok 4.1 Fast",
137
+ "grok-4-3": "Grok 4.3",
138
+ "grok-4-20": "Grok 4.20",
139
+ "deepseek-v4-pro": "DeepSeek V4 Pro",
140
+ };
141
+ function modelLabelFor(v) {
142
+ if (!v) return "";
143
+ return MODEL_LABELS[v] || v;
144
+ }
145
+
146
+ const NAMES = {};
147
+
148
+ // 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
+ const ROOM_STATE = {
152
+ title: "the minimum viable structure of a data flywheel",
153
+ 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?",
154
+ number: 47,
155
+ status: "live",
156
+ turns: 5,
157
+ elapsed: "04:32",
158
+ opened: "Apr 28",
159
+ members: ["socrates", "first-principles", "value-investor"],
160
+ mode: "constructive",
161
+ intensity: "sharp",
162
+ style: "auto",
163
+ incognito: false,
164
+ history: [
165
+ { ts: "Apr 28 · 21:08", who: "system", kind: "open", label: "room opened" }
166
+ ]
167
+ };
168
+
169
+ // Staged changes layered on top of ROOM_STATE — committed only when
170
+ // the user clicks Confirm. The shape mirrors the room config keys.
171
+ let STAGED = { mode: null, intensity: null, incognito: null };
172
+ // Snapshot of ROOM_STATE.members at overlay-open time. The members
173
+ // array itself is mutated optimistically by add/removeMember; this
174
+ // baseline lets us detect "dirty" by diffing and lets us roll back
175
+ // on Cancel.
176
+ let MEMBERS_BASELINE = [];
177
+
178
+ function effective(field) {
179
+ return STAGED[field] !== null ? STAGED[field] : ROOM_STATE[field];
180
+ }
181
+ function membersDirty() {
182
+ if (ROOM_STATE.members.length !== MEMBERS_BASELINE.length) return true;
183
+ const baseSet = new Set(MEMBERS_BASELINE);
184
+ for (const m of ROOM_STATE.members) {
185
+ if (!baseSet.has(m)) return true;
186
+ }
187
+ return false;
188
+ }
189
+ function isDirty() {
190
+ return (
191
+ STAGED.mode !== null ||
192
+ STAGED.intensity !== null ||
193
+ STAGED.incognito !== null ||
194
+ membersDirty()
195
+ );
196
+ }
197
+ function resetStaged() {
198
+ STAGED = { mode: null, intensity: null, incognito: null };
199
+ }
200
+
201
+ const MODES = [
202
+ { v: "brainstorm", label: "Brainstorm", desc: "yes-and" },
203
+ { v: "constructive", label: "Constructive", desc: "push & sharpen" },
204
+ { v: "debate", label: "Debate", desc: "find holes" },
205
+ { v: "no-mercy", label: "No Mercy", desc: "tear apart" }
206
+ ];
207
+
208
+
209
+ function escape(s) {
210
+ return String(s).replace(/[&<>"']/g, (c) => ({
211
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
212
+ }[c]));
213
+ }
214
+
215
+ function nowStamp() {
216
+ const d = new Date();
217
+ const month = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"][d.getMonth()];
218
+ const hh = String(d.getHours()).padStart(2, "0");
219
+ const mm = String(d.getMinutes()).padStart(2, "0");
220
+ return `${month} ${d.getDate()} · ${hh}:${mm}`;
221
+ }
222
+ function clockOnly() {
223
+ const d = new Date();
224
+ const hh = String(d.getHours()).padStart(2, "0");
225
+ const mm = String(d.getMinutes()).padStart(2, "0");
226
+ return `${hh}:${mm}`;
227
+ }
228
+
229
+ /* ─── Markup builders ─── */
230
+
231
+ /** Flat row mirroring the new-room composer's `.composer-pick-row` ·
232
+ * checkbox · avatar · name + role · model badge · info button. The
233
+ * whole row is clickable; toggles staged membership. Avatar + info
234
+ * click separately to open the agent profile. The footer's
235
+ * [Confirm] / [Cancel] commits the staged changes — no per-row
236
+ * two-click confirm any more. */
237
+ function memberRowHTML(d, isActive) {
238
+ const modelLabel = modelLabelFor(d.modelV);
239
+ const modelHtml = modelLabel
240
+ ? `<span class="rs-member-model" title="${escape(modelLabel)}">${escape(modelLabel)}</span>`
241
+ : "";
242
+ return `
243
+ <div class="rs-member${isActive ? " on" : ""}" data-slug="${d.slug}" data-rs-toggle role="button" tabindex="0">
244
+ <input type="checkbox" class="rs-member-check" data-slug="${d.slug}"${isActive ? " checked" : ""}>
245
+ <div class="rs-member-img" data-agent-link="${d.slug}">
246
+ <img class="rs-member-av" src="${escape(d.avatar)}" alt="${escape(d.name)}" data-agent-link="${d.slug}">
247
+ </div>
248
+ <div class="rs-member-main">
249
+ <span class="rs-member-name">${escape(d.name)}</span>
250
+ <span class="rs-member-tag">${escape(d.role)}</span>
251
+ </div>
252
+ ${modelHtml}
253
+ <button type="button" class="rs-member-info" data-agent-link="${d.slug}" aria-label="Open ${escape(d.name)} profile">i</button>
254
+ </div>
255
+ `;
256
+ }
257
+
258
+ function historyRowHTML(h) {
259
+ let icon = "·";
260
+ let cls = "h-misc";
261
+ if (h.kind === "member-add") { icon = "+"; cls = "h-add"; }
262
+ if (h.kind === "member-remove") { icon = "−"; cls = "h-remove"; }
263
+ if (h.kind === "mode") { icon = "↔"; cls = "h-config"; }
264
+ if (h.kind === "style") { icon = "↔"; cls = "h-config"; }
265
+ if (h.kind === "intensity") { icon = "↔"; cls = "h-config"; }
266
+ if (h.kind === "open") { icon = "▸"; cls = "h-system"; }
267
+ return `
268
+ <li class="rs-history-row ${cls}">
269
+ <span class="h-time">${escape(h.ts)}</span>
270
+ <span class="h-icon">${icon}</span>
271
+ <span class="h-label">${escape(h.label)}</span>
272
+ <span class="h-who">${escape(h.who)}</span>
273
+ </li>
274
+ `;
275
+ }
276
+
277
+ function modalHTML() {
278
+ return `
279
+ <div class="room-settings-overlay" id="room-settings-overlay" role="dialog" aria-modal="true" aria-hidden="true">
280
+ <div class="room-settings-modal" role="document">
281
+
282
+ <div class="rs-classification">
283
+ <span><span class="dot">●</span> room · settings</span>
284
+ <span class="right">// private</span>
285
+ </div>
286
+
287
+ <header class="rs-head">
288
+ <div>
289
+ <div class="meta">// room #<span class="rs-number">${ROOM_STATE.number}</span> · <span class="live">${ROOM_STATE.status}</span> · <span class="rs-turns">${ROOM_STATE.turns}</span> turns</div>
290
+ <div class="title rs-title">${escape(ROOM_STATE.title)}</div>
291
+ </div>
292
+ <button type="button" class="close-btn" aria-label="Close">✕</button>
293
+ </header>
294
+
295
+ <!-- Single-page spec sheet · four compact rows. Members
296
+ opens a director picker popover (mirrors the new-room
297
+ composer's pattern); tone / intensity are inline chip
298
+ rows; memory is a single toggle. -->
299
+ <div class="rs-body">
300
+ <div class="rs-config-list">
301
+
302
+ <div class="rs-config-row">
303
+ <div class="rs-config-row-label">
304
+ <span class="rs-config-row-name">Directors</span>
305
+ <span class="rs-config-row-hint">at this table</span>
306
+ </div>
307
+ <div class="rs-cast-wrap">
308
+ <button type="button" class="rs-cast-btn" data-rs-cast-trigger>
309
+ <span class="rs-cast-stack" data-rs-cast-stack></span>
310
+ <span class="rs-cast-count" data-rs-cast-count>—</span>
311
+ <span class="rs-cast-chevron">▾</span>
312
+ </button>
313
+ </div>
314
+ </div>
315
+
316
+ <div class="rs-config-row">
317
+ <div class="rs-config-row-label">
318
+ <span class="rs-config-row-name">Tone</span>
319
+ <span class="rs-config-row-hint">how hard they push</span>
320
+ </div>
321
+ <div class="rs-mode-grid rs-mode-row"></div>
322
+ </div>
323
+
324
+ <div class="rs-config-row">
325
+ <div class="rs-config-row-label">
326
+ <span class="rs-config-row-name">Intensity</span>
327
+ <span class="rs-config-row-hint" data-rs-intensity-hint>currently: sharp</span>
328
+ </div>
329
+ <div class="rs-intensity-chips">
330
+ <button type="button" class="rs-chip rs-chip-mini" data-rs-intensity-pick="calm">
331
+ <span class="rs-chip-label">Calm</span>
332
+ <span class="rs-chip-info rs-info-trigger" data-info-title="Calm" data-info-body="${escape(INTENSITY_TIPS.calm)}" tabindex="-1" aria-label="What 'Calm' means">i</span>
333
+ </button>
334
+ <button type="button" class="rs-chip rs-chip-mini" data-rs-intensity-pick="sharp">
335
+ <span class="rs-chip-label">Sharp</span>
336
+ <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
+ </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>
342
+ </div>
343
+ </div>
344
+
345
+ <div class="rs-config-row">
346
+ <div class="rs-config-row-label">
347
+ <span class="rs-config-row-name">Memory</span>
348
+ <span class="rs-config-row-hint">long-term learning about you</span>
349
+ </div>
350
+ <label class="rs-toggle-row" data-rs-incognito-label>
351
+ <input type="checkbox" class="rs-incognito-check" data-rs-incognito-check>
352
+ <span class="rs-toggle-label">Incognito — don't extract memory from this room</span>
353
+ </label>
354
+ </div>
355
+
356
+ </div>
357
+ </div>
358
+
359
+ <footer class="rs-foot">
360
+ <div class="saved" data-rs-status>no pending changes</div>
361
+ <div style="display: flex; gap: 6px;">
362
+ <button type="button" class="rs-action rs-cancel" data-rs-cancel>[ Cancel ]</button>
363
+ <button type="button" class="rs-action rs-done" data-rs-confirm>[ Confirm ]</button>
364
+ </div>
365
+ </footer>
366
+
367
+ </div>
368
+ </div>
369
+ `;
370
+ }
371
+
372
+ let overlay, modal;
373
+ let confirmTimers = new Map(); // slug → timeout id (for two-click confirm reset)
374
+
375
+ /* ─── Renderers ─── */
376
+
377
+ /** Refresh the agent-name cache (used by history labels / shortHandle). */
378
+ function refreshAgentCache() {
379
+ for (const d of getAvailableAgents()) NAMES[d.slug] = d.name;
380
+ }
381
+
382
+ /** Compact "Directors" trigger button · stacks the first ~5 active
383
+ * avatars with a +N overflow chip and the count. Mirrors the new-
384
+ * room composer's `.cmp-cast-btn`. Click opens the picker popover. */
385
+ function renderMembers() {
386
+ refreshAgentCache();
387
+ const active = ROOM_STATE.members;
388
+ const all = getAvailableAgents();
389
+ const activeAgents = active.map((slug) => all.find((a) => a.slug === slug)).filter(Boolean);
390
+
391
+ const stack = modal.querySelector("[data-rs-cast-stack]");
392
+ const countEl = modal.querySelector("[data-rs-cast-count]");
393
+ if (!stack || !countEl) return;
394
+
395
+ const SHOW = 5;
396
+ const shown = activeAgents.slice(0, SHOW);
397
+ const overflow = Math.max(0, activeAgents.length - SHOW);
398
+ stack.innerHTML = shown.map((a) =>
399
+ `<img class="rs-cast-av" src="${escape(a.avatar)}" alt="${escape(a.name)}" title="${escape(a.name)}">`
400
+ ).join("") + (overflow > 0 ? `<span class="rs-cast-more">+${overflow}</span>` : "");
401
+ countEl.textContent = `${activeAgents.length} ${activeAgents.length === 1 ? "director" : "directors"}`;
402
+
403
+ // Picker popover · re-render rows when membership changes so the
404
+ // open popover (if any) reflects the staged state immediately.
405
+ renderCastPickerRows();
406
+ }
407
+
408
+ function renderCastPickerRows() {
409
+ const pop = document.getElementById("rs-cast-pop");
410
+ if (!pop) return;
411
+ const list = pop.querySelector("[data-rs-cast-list]");
412
+ if (!list) return;
413
+ const active = ROOM_STATE.members;
414
+ const all = getAvailableAgents();
415
+ if (all.length === 0) {
416
+ list.innerHTML = `<div class="rs-cast-empty">No directors yet — create one in the Agents tab.</div>`;
417
+ return;
418
+ }
419
+ const activeRows = all.filter((d) => active.includes(d.slug));
420
+ const inactiveRows = all.filter((d) => !active.includes(d.slug));
421
+ list.innerHTML =
422
+ activeRows.map((d) => memberRowHTML(d, true)).join("") +
423
+ inactiveRows.map((d) => memberRowHTML(d, false)).join("");
424
+ }
425
+
426
+ /** Open the directors picker popover anchored under the trigger
427
+ * button. Mirrors the new-room composer's `.composer-pick-pop`
428
+ * pattern. Idempotent — calling toggle while open closes it. */
429
+ function toggleCastPicker(triggerBtn) {
430
+ const existing = document.getElementById("rs-cast-pop");
431
+ if (existing) {
432
+ closeCastPicker();
433
+ return;
434
+ }
435
+ const pop = document.createElement("div");
436
+ pop.id = "rs-cast-pop";
437
+ pop.className = "rs-cast-pop";
438
+ pop.innerHTML = `
439
+ <div class="rs-cast-head">
440
+ <span class="rs-cast-title">// directors at this table</span>
441
+ <span class="rs-cast-hint">click a row to toggle</span>
442
+ </div>
443
+ <div class="rs-cast-list" data-rs-cast-list></div>
444
+ `;
445
+ document.body.appendChild(pop);
446
+ renderCastPickerRows();
447
+ // Row click → toggle staged membership (or open agent profile when
448
+ // the avatar / info button is the click target). The popover lives
449
+ // outside .room-settings-modal so the modal-level click handler
450
+ // doesn't catch these — wire them directly here.
451
+ pop.addEventListener("click", (ev) => {
452
+ const profileLink = ev.target.closest("[data-agent-link]");
453
+ if (profileLink && (ev.target.closest(".rs-member-img") || ev.target.closest(".rs-member-info"))) {
454
+ ev.preventDefault();
455
+ ev.stopPropagation();
456
+ const slug = profileLink.getAttribute("data-agent-link");
457
+ if (typeof window.openAgentOverlay === "function" && slug) window.openAgentOverlay(slug);
458
+ return;
459
+ }
460
+ const row = ev.target.closest("[data-rs-toggle]");
461
+ if (!row) return;
462
+ ev.preventDefault();
463
+ ev.stopPropagation();
464
+ const slug = row.getAttribute("data-slug");
465
+ if (!slug) return;
466
+ if (ROOM_STATE.members.includes(slug)) removeMember(slug);
467
+ else addMember(slug);
468
+ });
469
+ // Position under the trigger, right-aligned to it for breathing
470
+ // room from the modal's left edge.
471
+ const r = triggerBtn.getBoundingClientRect();
472
+ const popW = 360;
473
+ let left = r.right - popW;
474
+ if (left < 12) left = Math.max(12, r.left);
475
+ pop.style.left = left + "px";
476
+ pop.style.top = (r.bottom + 6) + "px";
477
+ pop.style.width = popW + "px";
478
+ triggerBtn.classList.add("on");
479
+
480
+ // Outside-click + Esc to close. Stored on closures so closeCastPicker
481
+ // can detach them. Click is exempt when:
482
+ // · inside the picker popover itself
483
+ // · on the trigger button (handled by its own toggle)
484
+ // · anywhere inside the agent intro overlay — opening + closing
485
+ // an agent profile from inside the picker should leave the
486
+ // picker open so the user can keep browsing directors
487
+ castPickerOutside = (ev) => {
488
+ if (
489
+ !pop.contains(ev.target)
490
+ && !ev.target.closest("[data-rs-cast-trigger]")
491
+ && !ev.target.closest(".agent-overlay")
492
+ ) {
493
+ closeCastPicker();
494
+ }
495
+ };
496
+ castPickerEsc = (ev) => {
497
+ if (ev.key === "Escape") closeCastPicker();
498
+ };
499
+ setTimeout(() => {
500
+ document.addEventListener("click", castPickerOutside, true);
501
+ document.addEventListener("keydown", castPickerEsc, true);
502
+ }, 0);
503
+ }
504
+ let castPickerOutside = null;
505
+ let castPickerEsc = null;
506
+ function closeCastPicker() {
507
+ const pop = document.getElementById("rs-cast-pop");
508
+ if (pop) pop.remove();
509
+ document.querySelectorAll("[data-rs-cast-trigger].on").forEach((b) => b.classList.remove("on"));
510
+ if (castPickerOutside) document.removeEventListener("click", castPickerOutside, true);
511
+ if (castPickerEsc) document.removeEventListener("keydown", castPickerEsc, true);
512
+ castPickerOutside = null;
513
+ castPickerEsc = null;
514
+ }
515
+
516
+ function renderModes() {
517
+ const grid = modal.querySelector(".rs-mode-row");
518
+ const cur = effective("mode");
519
+ // Each chip pairs a tight label + a small `i` icon · hovering the
520
+ // icon opens a description popover (TONE_TIPS lookup). Sharp-edge,
521
+ // no border-radius, no second line.
522
+ grid.innerHTML = MODES.map((m) => {
523
+ const tip = TONE_TIPS[m.v] || m.desc || "";
524
+ return `<button type="button" class="rs-chip rs-chip-mini${m.v === cur ? " active" : ""}" data-mode="${m.v}">
525
+ <span class="rs-chip-label">${escape(m.label)}</span>
526
+ <span class="rs-chip-info rs-info-trigger" data-info-title="${escape(m.label)}" data-info-body="${escape(tip)}" tabindex="-1" aria-label="What '${escape(m.label)}' means">i</span>
527
+ </button>`;
528
+ }).join("");
529
+ }
530
+
531
+ function renderIncognito() {
532
+ const checkbox = modal.querySelector("[data-rs-incognito-check]");
533
+ if (!checkbox) return;
534
+ const effective = STAGED.incognito !== null ? STAGED.incognito : ROOM_STATE.incognito;
535
+ checkbox.checked = !!effective;
536
+ }
537
+
538
+ function renderIntensity() {
539
+ // Intensity is now a 3-chip row (Calm / Sharp / Brutal) instead of
540
+ // a slider · highlight the active chip. The hint line above shows
541
+ // "currently: <value>" so the picked state stays self-evident.
542
+ const cur = effective("intensity");
543
+ modal.querySelectorAll("[data-rs-intensity-pick]").forEach((el) => {
544
+ el.classList.toggle("active", el.dataset.rsIntensityPick === cur);
545
+ });
546
+ const intensityHint = modal.querySelector('[data-rs-intensity-hint]');
547
+ if (intensityHint) intensityHint.textContent = `currently: ${cur}`;
548
+ }
549
+
550
+ function renderConfirmState() {
551
+ const status = modal.querySelector("[data-rs-status]");
552
+ const btn = modal.querySelector("[data-rs-confirm]");
553
+ if (!status || !btn) return;
554
+ status.classList.remove("error");
555
+ if (isDirty()) {
556
+ const parts = [];
557
+ if (STAGED.mode !== null) parts.push("tone");
558
+ if (STAGED.intensity !== null) parts.push("intensity");
559
+ if (membersDirty()) parts.push("members");
560
+ status.textContent = `pending: ${parts.join(", ")} — click Confirm to apply`;
561
+ status.classList.add("pending");
562
+ btn.classList.add("dirty");
563
+ btn.disabled = false;
564
+ } else {
565
+ status.textContent = "no pending changes";
566
+ status.classList.remove("pending");
567
+ btn.classList.remove("dirty");
568
+ btn.disabled = false;
569
+ }
570
+ }
571
+
572
+ function renderHistory() {
573
+ // History pane removed from the overlay UI · keep the function as
574
+ // a no-op so callers (init / logEvent) don't need to coordinate.
575
+ // ROOM_STATE.history is still populated for backend correlation.
576
+ const list = modal.querySelector(".rs-history-list");
577
+ if (!list) return;
578
+ const recent = ROOM_STATE.history.slice().reverse();
579
+ list.innerHTML = recent.map(historyRowHTML).join("");
580
+ }
581
+
582
+ /* ─── Open/close ─── */
583
+
584
+ /** Pull baseline state from the live app (or fall back to demo state). */
585
+ function syncBaseline() {
586
+ const app = window.app;
587
+ const room = app?.currentRoom;
588
+ if (!room) return;
589
+ ROOM_STATE.title = room.subject || ROOM_STATE.title;
590
+ ROOM_STATE.topic = room.subject || ROOM_STATE.topic;
591
+ ROOM_STATE.number = typeof room.number === "number" ? room.number : ROOM_STATE.number;
592
+ ROOM_STATE.status = room.status || ROOM_STATE.status;
593
+ ROOM_STATE.mode = room.mode || "constructive";
594
+ ROOM_STATE.intensity = room.intensity || "sharp";
595
+ ROOM_STATE.incognito = room.incognito === true;
596
+ if (Array.isArray(app.currentMembers) && app.currentMembers.length) {
597
+ ROOM_STATE.members = app.currentMembers.map((a) => a.id);
598
+ }
599
+ // Snapshot baseline AFTER syncing so dirty-detection compares against
600
+ // the server's authoritative member set, not the previous open's.
601
+ MEMBERS_BASELINE = ROOM_STATE.members.slice();
602
+ // Refresh title surfaces inside the modal that snapshot strings.
603
+ const num = modal.querySelector(".rs-number");
604
+ if (num) num.textContent = ROOM_STATE.number;
605
+ const ttl = modal.querySelector(".rs-title");
606
+ if (ttl) ttl.textContent = ROOM_STATE.title;
607
+ }
608
+
609
+ function open() {
610
+ if (!overlay) return;
611
+ resetStaged();
612
+ syncBaseline();
613
+ // Re-render the members list every open · window.app.agents may have
614
+ // grown (a new custom agent was just created in the Agents tab) since
615
+ // the modal was last mounted, and the inactive-member catalog needs
616
+ // to reflect that without requiring a page reload.
617
+ renderMembers();
618
+ renderModes();
619
+ renderIntensity();
620
+ renderIncognito();
621
+ renderConfirmState();
622
+ closeCastPicker();
623
+ overlay.classList.add("open");
624
+ overlay.setAttribute("aria-hidden", "false");
625
+ document.body.style.overflow = "hidden";
626
+ }
627
+ function close() {
628
+ if (!overlay) return;
629
+ overlay.classList.remove("open");
630
+ overlay.setAttribute("aria-hidden", "true");
631
+ document.body.style.overflow = "";
632
+ // Reset any pending confirms (member removal two-step)
633
+ confirmTimers.forEach((id) => clearTimeout(id));
634
+ confirmTimers.clear();
635
+ closeCastPicker();
636
+ // Drop any unconfirmed config changes — they were never persisted.
637
+ resetStaged();
638
+ // Roll back optimistic member edits to the baseline taken on open.
639
+ ROOM_STATE.members = MEMBERS_BASELINE.slice();
640
+ }
641
+
642
+ /* ─── Mutations + history logging ─── */
643
+
644
+ function logEvent(evt) {
645
+ // History pane removed from the overlay UI · keep the in-memory
646
+ // log for backend-event correlation but no DOM render. The chair
647
+ // settings-change announcement still surfaces inline in chat.
648
+ const stamped = Object.assign({ ts: nowStamp(), who: "you" }, evt);
649
+ ROOM_STATE.history.push(stamped);
650
+ }
651
+
652
+ function addMember(slug) {
653
+ if (ROOM_STATE.members.includes(slug)) return;
654
+ ROOM_STATE.members.push(slug);
655
+ renderMembers();
656
+ logEvent({
657
+ kind: "member-add",
658
+ payload: slug,
659
+ label: `added @${shortHandle(slug)}`
660
+ });
661
+ }
662
+
663
+ function removeMember(slug) {
664
+ const idx = ROOM_STATE.members.indexOf(slug);
665
+ if (idx < 0) return;
666
+ if (ROOM_STATE.members.length <= 1) return; // floor at 1
667
+ ROOM_STATE.members.splice(idx, 1);
668
+ renderMembers();
669
+ logEvent({
670
+ kind: "member-remove",
671
+ payload: slug,
672
+ label: `removed @${shortHandle(slug)}`
673
+ });
674
+ }
675
+
676
+ function shortHandle(slug) {
677
+ const map = {
678
+ "socrates": "socrates",
679
+ "first-principles": "first_p",
680
+ "value-investor": "value_inv",
681
+ "user-empathy": "user_emp",
682
+ "long-horizon": "long_h",
683
+ "phenomenologist": "phen"
684
+ };
685
+ return map[slug] || slug;
686
+ }
687
+
688
+ // Staging — chip clicks only set STAGED.* and re-render the chip rows.
689
+ // The change is committed when the user clicks Confirm (see commit()).
690
+ function stageMode(next) {
691
+ STAGED.mode = next === ROOM_STATE.mode ? null : next;
692
+ renderModes();
693
+ renderConfirmState();
694
+ }
695
+ function stageIntensity(next) {
696
+ if (!["calm", "sharp", "brutal"].includes(next)) return;
697
+ STAGED.intensity = next === ROOM_STATE.intensity ? null : next;
698
+ renderIntensity();
699
+ renderConfirmState();
700
+ }
701
+ function stageIncognito(next) {
702
+ STAGED.incognito = !!next === ROOM_STATE.incognito ? null : !!next;
703
+ renderIncognito();
704
+ renderConfirmState();
705
+ }
706
+
707
+ /** Push staged config + member changes to the backend. */
708
+ async function commit() {
709
+ if (!isDirty()) { close(); return; }
710
+
711
+ // Build the settings patch (mode / intensity / incognito).
712
+ const patch = {};
713
+ if (STAGED.mode !== null) patch.mode = STAGED.mode;
714
+ if (STAGED.intensity !== null) patch.intensity = STAGED.intensity;
715
+ if (STAGED.incognito !== null) patch.incognito = STAGED.incognito;
716
+
717
+ const btn = modal.querySelector("[data-rs-confirm]");
718
+ const status = modal.querySelector("[data-rs-status]");
719
+ if (btn) { btn.disabled = true; btn.textContent = "[ Applying… ]"; }
720
+
721
+ try {
722
+ if (Object.keys(patch).length > 0 && window.app && typeof window.app.updateRoomSettings === "function") {
723
+ await window.app.updateRoomSettings(patch);
724
+ }
725
+ // Members PATCH · only fire when the membership actually changed.
726
+ // Server diffs against current and runs the chair announcement +
727
+ // queue injection. We don't await SSE — the chair message and
728
+ // speaker turns flow in via the existing room stream.
729
+ if (membersDirty() && window.app?.currentRoomId) {
730
+ const r = await fetch(
731
+ "/api/rooms/" + encodeURIComponent(window.app.currentRoomId) + "/members",
732
+ {
733
+ method: "PATCH",
734
+ headers: { "content-type": "application/json" },
735
+ body: JSON.stringify({ agentIds: ROOM_STATE.members }),
736
+ },
737
+ );
738
+ if (!r.ok) {
739
+ const j = await r.json().catch(() => ({}));
740
+ throw new Error(j.error || ("members update failed: HTTP " + r.status));
741
+ }
742
+ const j = await r.json();
743
+ // Refresh the app's authoritative member list so sidebars / chips
744
+ // re-render against the new roster on the next paint.
745
+ if (Array.isArray(j.members) && window.app) {
746
+ // Resolve each member id to the full agent record we already have.
747
+ const byId = {};
748
+ for (const a of (window.app.agents || [])) byId[a.id] = a;
749
+ window.app.currentMembers = j.members
750
+ .map((m) => byId[m.agentId])
751
+ .filter(Boolean);
752
+ }
753
+ MEMBERS_BASELINE = ROOM_STATE.members.slice();
754
+ }
755
+ // Snapshot the previous values BEFORE we overwrite, so the chat
756
+ // markers can show "before → after" honestly.
757
+ const before = {
758
+ mode: ROOM_STATE.mode,
759
+ intensity: ROOM_STATE.intensity,
760
+ incognito: ROOM_STATE.incognito,
761
+ };
762
+ const after = {
763
+ mode: patch.mode ?? ROOM_STATE.mode,
764
+ intensity: patch.intensity ?? ROOM_STATE.intensity,
765
+ incognito: typeof patch.incognito === "boolean" ? patch.incognito : ROOM_STATE.incognito,
766
+ };
767
+ Object.assign(ROOM_STATE, after);
768
+ if (STAGED.mode !== null) {
769
+ logEvent({ kind: "mode", before: before.mode, after: after.mode,
770
+ label: `tone: ${before.mode} → ${after.mode}` });
771
+ }
772
+ if (STAGED.intensity !== null) {
773
+ logEvent({ kind: "intensity", before: before.intensity, after: after.intensity,
774
+ label: `intensity: ${before.intensity} → ${after.intensity}` });
775
+ }
776
+ if (STAGED.incognito !== null) {
777
+ logEvent({ kind: "incognito", before: before.incognito, after: after.incognito,
778
+ label: `memory: ${before.incognito ? "incognito" : "default"} → ${after.incognito ? "incognito" : "default"}` });
779
+ }
780
+ resetStaged();
781
+ if (btn) { btn.disabled = false; btn.textContent = "[ Confirm ]"; }
782
+ close();
783
+ } catch (e) {
784
+ if (btn) { btn.disabled = false; btn.textContent = "[ Confirm ]"; }
785
+ if (status) {
786
+ status.textContent = `failed: ${e && e.message ? e.message : e}`;
787
+ status.classList.add("error");
788
+ }
789
+ }
790
+ }
791
+
792
+ /* ─── Chat marker injection ─── */
793
+
794
+ function injectChatMarker(evt) {
795
+ const chat = document.querySelector(".chat");
796
+ if (!chat) return;
797
+
798
+ const wrap = document.createElement("div");
799
+ wrap.className = "config-marker";
800
+ wrap.dataset.kind = evt.kind;
801
+
802
+ let symbol = "·";
803
+ if (evt.kind === "member-add") symbol = "+";
804
+ if (evt.kind === "member-remove") symbol = "−";
805
+ if (evt.kind === "mode" || evt.kind === "style" || evt.kind === "intensity") symbol = "↔";
806
+
807
+ wrap.innerHTML = `
808
+ <span class="cm-line"></span>
809
+ <span class="cm-body">
810
+ <span class="cm-time">${escape(clockOnly())}</span>
811
+ <span class="cm-icon">${symbol}</span>
812
+ <span class="cm-label">${escape(evt.label)}</span>
813
+ <span class="cm-who">${escape(evt.who || "you")}</span>
814
+ </span>
815
+ <span class="cm-line"></span>
816
+ `;
817
+
818
+ // Insert at the end of the chat (latest activity)
819
+ chat.appendChild(wrap);
820
+ // Auto-scroll if user is near bottom
821
+ if (chat.scrollHeight - chat.scrollTop - chat.clientHeight < 200) {
822
+ chat.scrollTop = chat.scrollHeight;
823
+ }
824
+ }
825
+
826
+ /* ─── Click handler with two-step confirm ─── */
827
+
828
+ function handleRemoveClick(button) {
829
+ const slug = button.dataset.slug;
830
+ const state = button.dataset.state || "idle";
831
+
832
+ if (state === "idle") {
833
+ // First click: arm the confirm state
834
+ button.dataset.state = "confirm";
835
+ const t = setTimeout(() => {
836
+ if (button.dataset.state === "confirm") button.dataset.state = "idle";
837
+ confirmTimers.delete(slug);
838
+ }, 4000);
839
+ // Cancel any prior timer for this slug
840
+ if (confirmTimers.has(slug)) clearTimeout(confirmTimers.get(slug));
841
+ confirmTimers.set(slug, t);
842
+ return;
843
+ }
844
+
845
+ if (state === "confirm") {
846
+ // Second click within window: actually remove
847
+ if (confirmTimers.has(slug)) {
848
+ clearTimeout(confirmTimers.get(slug));
849
+ confirmTimers.delete(slug);
850
+ }
851
+ removeMember(slug);
852
+ }
853
+ }
854
+
855
+ /* ─── Init ─── */
856
+
857
+ function init() {
858
+ if (document.getElementById("room-settings-overlay")) return;
859
+ const wrap = document.createElement("div");
860
+ wrap.innerHTML = modalHTML().trim();
861
+ document.body.appendChild(wrap.firstChild);
862
+
863
+ overlay = document.getElementById("room-settings-overlay");
864
+ modal = overlay.querySelector(".room-settings-modal");
865
+
866
+ renderMembers();
867
+ renderModes();
868
+ renderIntensity();
869
+ renderIncognito();
870
+ renderHistory();
871
+ renderConfirmState();
872
+
873
+ // Close (X) — discards staged changes via close().
874
+ overlay.querySelector(".close-btn").addEventListener("click", close);
875
+ // Confirm — push staged changes to the backend then close.
876
+ modal.querySelector("[data-rs-confirm]").addEventListener("click", (e) => {
877
+ e.preventDefault();
878
+ void commit();
879
+ });
880
+ // Cancel — discards staged changes (close() resets) and closes the modal.
881
+ const cancelBtn = modal.querySelector("[data-rs-cancel]");
882
+ if (cancelBtn) {
883
+ cancelBtn.addEventListener("click", (e) => { e.preventDefault(); close(); });
884
+ }
885
+ overlay.addEventListener("click", (e) => {
886
+ if (e.target === overlay) close();
887
+ });
888
+ document.addEventListener("keydown", (e) => {
889
+ if (e.key === "Escape" && overlay.classList.contains("open")) {
890
+ e.stopImmediatePropagation();
891
+ close();
892
+ }
893
+ });
894
+
895
+ // Triggers anywhere
896
+ document.addEventListener("click", (e) => {
897
+ if (e.target.closest("[data-room-settings-trigger]")) {
898
+ e.preventDefault();
899
+ open();
900
+ }
901
+ });
902
+
903
+ // Hover the info icon → open the description popover · close on
904
+ // mouseleave with a small grace window (handled inside
905
+ // openInfoPopover / scheduleClosePopover).
906
+ modal.addEventListener("mouseover", (e) => {
907
+ const info = e.target.closest(".rs-info-trigger");
908
+ if (info) openInfoPopover(info);
909
+ });
910
+ modal.addEventListener("mouseout", (e) => {
911
+ if (e.target.closest(".rs-info-trigger")) scheduleClosePopover();
912
+ });
913
+
914
+ // Capture-phase modal interactions
915
+ modal.addEventListener("click", (e) => {
916
+ // ─── Directors trigger · open / toggle the picker popover ──
917
+ const castTrigger = e.target.closest("[data-rs-cast-trigger]");
918
+ if (castTrigger) {
919
+ e.preventDefault();
920
+ e.stopPropagation();
921
+ toggleCastPicker(castTrigger);
922
+ return;
923
+ }
924
+ // Avatar / info button → open the agent's profile overlay.
925
+ const profileLink = e.target.closest("[data-agent-link]");
926
+ if (profileLink && (e.target.closest(".rs-member-av") || e.target.closest(".rs-member-img") || e.target.closest(".rs-member-info"))) {
927
+ e.preventDefault();
928
+ e.stopPropagation();
929
+ const slug = profileLink.getAttribute("data-agent-link");
930
+ if (typeof window.openAgentOverlay === "function" && slug) {
931
+ window.openAgentOverlay(slug);
932
+ }
933
+ return;
934
+ }
935
+ // Row click anywhere else → toggle staged membership. The footer's
936
+ // [Confirm] / [Cancel] commits / discards the change.
937
+ const memberRow = e.target.closest("[data-rs-toggle]");
938
+ if (memberRow) {
939
+ e.preventDefault();
940
+ e.stopPropagation();
941
+ const slug = memberRow.getAttribute("data-slug");
942
+ if (!slug) return;
943
+ if (ROOM_STATE.members.includes(slug)) {
944
+ removeMember(slug);
945
+ } else {
946
+ addMember(slug);
947
+ }
948
+ return;
949
+ }
950
+ // The `i` glyph on a chip is hover-driven (see mouseover wiring
951
+ // below) — clicking it should swallow the event so it doesn't
952
+ // also stage the chip's tone/intensity.
953
+ if (e.target.closest(".rs-info-trigger")) {
954
+ e.preventDefault();
955
+ e.stopPropagation();
956
+ return;
957
+ }
958
+ // Mode chip — stage only, commit on Confirm. Picks up clicks on
959
+ // the .rs-chip-label too via .closest.
960
+ const mc = e.target.closest("[data-mode]");
961
+ if (mc) {
962
+ e.preventDefault();
963
+ e.stopPropagation();
964
+ stageMode(mc.dataset.mode);
965
+ return;
966
+ }
967
+ // Intensity slider · click handled by pointer events (below) so drag
968
+ // can begin on the same gesture. Capture-phase clicks are no-ops.
969
+ if (e.target.closest(".rs-temp-bar")) {
970
+ e.preventDefault();
971
+ e.stopPropagation();
972
+ return;
973
+ }
974
+ const ip = e.target.closest("[data-rs-intensity-pick]");
975
+ if (ip) {
976
+ e.preventDefault();
977
+ e.stopPropagation();
978
+ stageIntensity(ip.dataset.rsIntensityPick);
979
+ return;
980
+ }
981
+ // Incognito · checkbox toggles the room's per-room memory
982
+ // opt-out. Native click on the input fires this — capture-phase
983
+ // is fine because we don't preventDefault (let the checkbox
984
+ // visually flip), we just stage the new value.
985
+ const incBox = e.target.closest("[data-rs-incognito-check]");
986
+ if (incBox) {
987
+ // Don't stop propagation · the native checkbox click event
988
+ // continues, then we read its checked state on next tick.
989
+ setTimeout(() => stageIncognito(incBox.checked), 0);
990
+ return;
991
+ }
992
+ }, true);
993
+
994
+ // Pointer-driven drag for the intensity slider (mouse + touch + stylus).
995
+ const bar = modal.querySelector(".rs-temp-bar");
996
+ if (bar) {
997
+ const valueAt = (clientX) => {
998
+ const rect = bar.getBoundingClientRect();
999
+ if (rect.width <= 0) return "sharp";
1000
+ const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
1001
+ return ratio < 0.33 ? "calm" : ratio < 0.67 ? "sharp" : "brutal";
1002
+ };
1003
+ bar.addEventListener("pointerdown", (e) => {
1004
+ e.preventDefault();
1005
+ bar.setPointerCapture(e.pointerId);
1006
+ bar.dataset.dragging = "1";
1007
+ stageIntensity(valueAt(e.clientX));
1008
+ });
1009
+ bar.addEventListener("pointermove", (e) => {
1010
+ if (bar.dataset.dragging !== "1") return;
1011
+ stageIntensity(valueAt(e.clientX));
1012
+ });
1013
+ const end = (e) => {
1014
+ if (bar.dataset.dragging !== "1") return;
1015
+ bar.dataset.dragging = "0";
1016
+ try { bar.releasePointerCapture(e.pointerId); } catch (_) {}
1017
+ };
1018
+ bar.addEventListener("pointerup", end);
1019
+ bar.addEventListener("pointercancel", end);
1020
+ }
1021
+ }
1022
+
1023
+ // Public API
1024
+ window.openRoomSettings = function () { if (!overlay) init(); open(); };
1025
+ window.closeRoomSettings = close;
1026
+ // Expose mutations so the picker can hand off (it imports addMember).
1027
+ window.RoomSettings = {
1028
+ addMember: addMember,
1029
+ removeMember: removeMember,
1030
+ getMembers: () => ROOM_STATE.members.slice(),
1031
+ getState: () => ROOM_STATE
1032
+ };
1033
+
1034
+ if (document.readyState === "loading") {
1035
+ document.addEventListener("DOMContentLoaded", init);
1036
+ } else {
1037
+ init();
1038
+ }
1039
+ })();