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.
@@ -166,6 +166,7 @@
166
166
  intensity: "sharp",
167
167
  style: "auto",
168
168
  incognito: false,
169
+ voteTrigger: "auto",
169
170
  history: [
170
171
  { ts: "Apr 28 · 21:08", who: "system", kind: "open", label: "room opened" }
171
172
  ]
@@ -173,7 +174,7 @@
173
174
 
174
175
  // Staged changes layered on top of ROOM_STATE — committed only when
175
176
  // the user clicks Confirm. The shape mirrors the room config keys.
176
- let STAGED = { mode: null, intensity: null, incognito: null };
177
+ let STAGED = { mode: null, intensity: null, incognito: null, voteTrigger: null };
177
178
  // Snapshot of ROOM_STATE.members at overlay-open time. The members
178
179
  // array itself is mutated optimistically by add/removeMember; this
179
180
  // baseline lets us detect "dirty" by diffing and lets us roll back
@@ -196,11 +197,12 @@
196
197
  STAGED.mode !== null ||
197
198
  STAGED.intensity !== null ||
198
199
  STAGED.incognito !== null ||
200
+ STAGED.voteTrigger !== null ||
199
201
  membersDirty()
200
202
  );
201
203
  }
202
204
  function resetStaged() {
203
- STAGED = { mode: null, intensity: null, incognito: null };
205
+ STAGED = { mode: null, intensity: null, incognito: null, voteTrigger: null };
204
206
  }
205
207
 
206
208
  const MODES = [
@@ -362,6 +364,29 @@
362
364
  </label>
363
365
  </div>
364
366
 
367
+ <div class="rs-config-row">
368
+ <div class="rs-config-row-label">
369
+ <span class="rs-config-row-name">Vote phase</span>
370
+ <span class="rs-config-row-hint">when the chair opens the round-end vote</span>
371
+ </div>
372
+ <div class="rs-vote-trigger-grp" role="radiogroup" aria-label="Vote phase trigger">
373
+ <label class="rs-radio-row" data-rs-vote-trigger-row="auto">
374
+ <input type="radio" name="rs-vote-trigger" value="auto" data-rs-vote-trigger-input>
375
+ <span class="rs-radio-label">
376
+ <span class="rs-radio-name">Auto</span>
377
+ <span class="rs-radio-desc">chair drops the vote prompt at every round wrap</span>
378
+ </span>
379
+ </label>
380
+ <label class="rs-radio-row" data-rs-vote-trigger-row="manual">
381
+ <input type="radio" name="rs-vote-trigger" value="manual" data-rs-vote-trigger-input>
382
+ <span class="rs-radio-label">
383
+ <span class="rs-radio-name">Manual</span>
384
+ <span class="rs-radio-desc">you trigger the vote with the bottom-bar button</span>
385
+ </span>
386
+ </label>
387
+ </div>
388
+ </div>
389
+
365
390
  </div>
366
391
  </div>
367
392
 
@@ -544,6 +569,16 @@
544
569
  checkbox.checked = !!effective;
545
570
  }
546
571
 
572
+ function renderVoteTrigger() {
573
+ const eff = STAGED.voteTrigger !== null ? STAGED.voteTrigger : ROOM_STATE.voteTrigger;
574
+ modal.querySelectorAll("[data-rs-vote-trigger-input]").forEach((input) => {
575
+ input.checked = input.value === eff;
576
+ });
577
+ modal.querySelectorAll("[data-rs-vote-trigger-row]").forEach((row) => {
578
+ row.classList.toggle("active", row.dataset.rsVoteTriggerRow === eff);
579
+ });
580
+ }
581
+
547
582
  function renderIntensity() {
548
583
  // Intensity is now a 3-chip row (Calm / Sharp / Terse) instead of
549
584
  // a slider · highlight the active chip. The hint line above shows
@@ -602,6 +637,7 @@
602
637
  ROOM_STATE.mode = room.mode || "constructive";
603
638
  ROOM_STATE.intensity = room.intensity || "sharp";
604
639
  ROOM_STATE.incognito = room.incognito === true;
640
+ ROOM_STATE.voteTrigger = room.voteTrigger === "manual" ? "manual" : "auto";
605
641
  if (Array.isArray(app.currentMembers) && app.currentMembers.length) {
606
642
  ROOM_STATE.members = app.currentMembers.map((a) => a.id);
607
643
  }
@@ -627,6 +663,7 @@
627
663
  renderModes();
628
664
  renderIntensity();
629
665
  renderIncognito();
666
+ renderVoteTrigger();
630
667
  renderConfirmState();
631
668
  closeCastPicker();
632
669
  overlay.classList.add("open");
@@ -738,16 +775,23 @@
738
775
  renderIncognito();
739
776
  renderConfirmState();
740
777
  }
778
+ function stageVoteTrigger(next) {
779
+ const v = next === "manual" ? "manual" : "auto";
780
+ STAGED.voteTrigger = v === ROOM_STATE.voteTrigger ? null : v;
781
+ renderVoteTrigger();
782
+ renderConfirmState();
783
+ }
741
784
 
742
785
  /** Push staged config + member changes to the backend. */
743
786
  async function commit() {
744
787
  if (!isDirty()) { close(); return; }
745
788
 
746
- // Build the settings patch (mode / intensity / incognito).
789
+ // Build the settings patch (mode / intensity / incognito / voteTrigger).
747
790
  const patch = {};
748
- if (STAGED.mode !== null) patch.mode = STAGED.mode;
749
- if (STAGED.intensity !== null) patch.intensity = STAGED.intensity;
750
- if (STAGED.incognito !== null) patch.incognito = STAGED.incognito;
791
+ if (STAGED.mode !== null) patch.mode = STAGED.mode;
792
+ if (STAGED.intensity !== null) patch.intensity = STAGED.intensity;
793
+ if (STAGED.incognito !== null) patch.incognito = STAGED.incognito;
794
+ if (STAGED.voteTrigger !== null) patch.voteTrigger = STAGED.voteTrigger;
751
795
 
752
796
  const btn = modal.querySelector("[data-rs-confirm]");
753
797
  const status = modal.querySelector("[data-rs-status]");
@@ -793,11 +837,13 @@
793
837
  mode: ROOM_STATE.mode,
794
838
  intensity: ROOM_STATE.intensity,
795
839
  incognito: ROOM_STATE.incognito,
840
+ voteTrigger: ROOM_STATE.voteTrigger,
796
841
  };
797
842
  const after = {
798
843
  mode: patch.mode ?? ROOM_STATE.mode,
799
844
  intensity: patch.intensity ?? ROOM_STATE.intensity,
800
845
  incognito: typeof patch.incognito === "boolean" ? patch.incognito : ROOM_STATE.incognito,
846
+ voteTrigger: patch.voteTrigger ?? ROOM_STATE.voteTrigger,
801
847
  };
802
848
  Object.assign(ROOM_STATE, after);
803
849
  if (STAGED.mode !== null) {
@@ -812,6 +858,19 @@
812
858
  logEvent({ kind: "incognito", before: before.incognito, after: after.incognito,
813
859
  label: `memory: ${before.incognito ? "incognito" : "default"} → ${after.incognito ? "incognito" : "default"}` });
814
860
  }
861
+ if (STAGED.voteTrigger !== null) {
862
+ logEvent({ kind: "voteTrigger", before: before.voteTrigger, after: after.voteTrigger,
863
+ label: `vote phase: ${before.voteTrigger} → ${after.voteTrigger}` });
864
+ }
865
+ // Mirror the new voteTrigger onto the live app.currentRoom so the
866
+ // bottom-bar manual button shows/hides immediately without
867
+ // waiting for an SSE round-trip.
868
+ if (STAGED.voteTrigger !== null && window.app && window.app.currentRoom) {
869
+ window.app.currentRoom.voteTrigger = after.voteTrigger;
870
+ if (typeof window.app.refreshManualVoteButton === "function") {
871
+ window.app.refreshManualVoteButton();
872
+ }
873
+ }
815
874
  resetStaged();
816
875
  if (btn) { btn.disabled = false; btn.textContent = "[ Confirm ]"; }
817
876
  close();
@@ -1040,6 +1099,13 @@
1040
1099
  setTimeout(() => stageIncognito(incBox.checked), 0);
1041
1100
  return;
1042
1101
  }
1102
+ // Vote trigger radio · same defer-and-read-state pattern as
1103
+ // incognito so the native input flip resolves before we stage.
1104
+ const vtInput = e.target.closest("[data-rs-vote-trigger-input]");
1105
+ if (vtInput) {
1106
+ setTimeout(() => stageVoteTrigger(vtInput.value), 0);
1107
+ return;
1108
+ }
1043
1109
  }, true);
1044
1110
 
1045
1111
  // Pointer-driven drag for the intensity slider (mouse + touch + stylus).
package/public/themes.css CHANGED
@@ -315,6 +315,44 @@
315
315
  --magenta: #C5839E;
316
316
  }
317
317
 
318
+ /* ─── NINTENDO · 8-bit NES palette · Mario red on dark sky ───
319
+ Built from the classic NES title-screen vocabulary: deep
320
+ night-sky background, pure-white NES title text, Mario red
321
+ as the primary accent (the slot called `--lime*` in this
322
+ codebase), coin-block gold for amber, fire-flower red for
323
+ warnings, and sky-cloud cyan for the moderator accent. Every
324
+ swatch is high-saturation / high-contrast in the NES family —
325
+ no muted desaturated tones (those don't belong in 8-bit). */
326
+ :root[data-theme="nintendo"] {
327
+ --bg: #181820;
328
+ --panel: #22222C;
329
+ --panel-2: #2A2A38;
330
+ --panel-3: #34344A;
331
+ --hi: #44445A;
332
+
333
+ --line: #2C2C3A;
334
+ --line-bright: #44445A;
335
+ --line-strong: #5C5C72;
336
+
337
+ --text: #FCFCFC;
338
+ --text-soft: #C8C8D8;
339
+ --text-dim: #8E8EA0;
340
+ --text-faint: #5C5C70;
341
+
342
+ /* Primary accent · Mario red. Token names stay `--lime*` so every
343
+ callsite that already references the primary keeps working. */
344
+ --lime: #E60012;
345
+ --lime-deep: #A40009;
346
+ --lime-dim: #4F0007;
347
+
348
+ --amber: #FBC000; /* coin / question block */
349
+ --amber-dim: #6B4D00;
350
+ --red: #FC4438; /* fire flower */
351
+ --red-dim: #6E1A14;
352
+ --cyan: #5BC0EB; /* Mario sky / chair moderator */
353
+ --magenta: #FC74FC; /* princess pink / power-up */
354
+ }
355
+
318
356
  /* ─── REGENT · gold-on-warm-dark · the eastwood twin
319
357
  The same warm dark palette as the default eastwood, with the
320
358
  primary forest-green accent swapped for the warm gold used by
@@ -142,6 +142,92 @@
142
142
  noise.stop(t0 + 0.06);
143
143
  }
144
144
 
145
+ /** Speaker-change cue · fires once when the round-table stage flips
146
+ * to a new speaker (idle → A, A → B). A short triangle-wave swoop
147
+ * at ~660 → 990 Hz, decaying over ~220ms — distinct from the
148
+ * keyboard-click texture of `tick()` so the ear reads it as a
149
+ * scene transition rather than typing. Same enabled-flag and
150
+ * AudioContext as tick · the user-settings toggle controls both. */
151
+ function speakerChange() {
152
+ if (!_enabled) return;
153
+ if (document.visibilityState !== "visible") return;
154
+ const ctx = ensureContext();
155
+ if (!ctx) return;
156
+ const t0 = ctx.currentTime;
157
+ const dur = 0.22;
158
+ const osc = ctx.createOscillator();
159
+ osc.type = "triangle";
160
+ // Brief upward sweep · 660Hz → 990Hz across the first 70ms, then
161
+ // hold while the gain envelope fades. Triangle waveform reads as
162
+ // softer / more "chime"-like than sine for this register.
163
+ osc.frequency.setValueAtTime(660, t0);
164
+ osc.frequency.exponentialRampToValueAtTime(990, t0 + 0.07);
165
+ const gain = ctx.createGain();
166
+ gain.gain.setValueAtTime(0.0001, t0);
167
+ gain.gain.exponentialRampToValueAtTime(0.085, t0 + 0.01);
168
+ gain.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
169
+ osc.connect(gain).connect(ctx.destination);
170
+ osc.start(t0);
171
+ osc.stop(t0 + dur);
172
+ }
173
+
174
+ /** Chair gavel cue · fires before chair voice playback begins in
175
+ * voice mode. Two-strike wooden knock that reads as "court is in
176
+ * session — listen up." Designed by ear:
177
+ * · Layer 1 · low sine ~190 Hz, 1ms attack + 220ms decay,
178
+ * gives the bass "thunk" of wood on wood.
179
+ * · Layer 2 · filtered noise burst at ~3.2 kHz, 30ms total,
180
+ * provides the sharp percussive transient.
181
+ * · Two strikes 160ms apart so the ear hears a deliberate
182
+ * "knock-knock" pattern (single strike read as a glitch /
183
+ * typewriter chime in early prototyping). */
184
+ function gavel() {
185
+ if (!_enabled) return;
186
+ if (document.visibilityState !== "visible") return;
187
+ const ctx = ensureContext();
188
+ if (!ctx) return;
189
+ const t0 = ctx.currentTime;
190
+ const strikes = [0, 0.16];
191
+ for (const offset of strikes) {
192
+ const t = t0 + offset;
193
+ // Body resonance · low sine, fat envelope.
194
+ const body = ctx.createOscillator();
195
+ body.type = "sine";
196
+ body.frequency.setValueAtTime(220, t);
197
+ body.frequency.exponentialRampToValueAtTime(140, t + 0.18);
198
+ const bodyGain = ctx.createGain();
199
+ bodyGain.gain.setValueAtTime(0.0001, t);
200
+ bodyGain.gain.exponentialRampToValueAtTime(0.18, t + 0.005);
201
+ bodyGain.gain.exponentialRampToValueAtTime(0.0001, t + 0.22);
202
+ body.connect(bodyGain).connect(ctx.destination);
203
+ body.start(t);
204
+ body.stop(t + 0.25);
205
+ // Transient click · 30ms filtered noise burst.
206
+ const noiseBuf = ctx.createBuffer(
207
+ 1,
208
+ Math.max(1, Math.floor(ctx.sampleRate * 0.03)),
209
+ ctx.sampleRate,
210
+ );
211
+ const noiseData = noiseBuf.getChannelData(0);
212
+ for (let i = 0; i < noiseData.length; i++) {
213
+ noiseData[i] = (Math.random() * 2 - 1) * (1 - i / noiseData.length);
214
+ }
215
+ const noise = ctx.createBufferSource();
216
+ noise.buffer = noiseBuf;
217
+ const bp = ctx.createBiquadFilter();
218
+ bp.type = "bandpass";
219
+ bp.frequency.value = 3200;
220
+ bp.Q.value = 0.8;
221
+ const noiseGain = ctx.createGain();
222
+ noiseGain.gain.setValueAtTime(0.0001, t);
223
+ noiseGain.gain.exponentialRampToValueAtTime(0.10, t + 0.003);
224
+ noiseGain.gain.exponentialRampToValueAtTime(0.0001, t + 0.035);
225
+ noise.connect(bp).connect(noiseGain).connect(ctx.destination);
226
+ noise.start(t);
227
+ noise.stop(t + 0.05);
228
+ }
229
+ }
230
+
145
231
  function setEnabled(on) {
146
232
  _enabled = !!on;
147
233
  writeEnabled(_enabled);
@@ -154,5 +240,5 @@
154
240
 
155
241
  // Public surface · attached to window so app.js (and the
156
242
  // user-settings toggle) can reach it without an import.
157
- window.boardroomTypingSfx = { tick, setEnabled, isEnabled };
243
+ window.boardroomTypingSfx = { tick, speakerChange, gavel, setEnabled, isEnabled };
158
244
  })();
@@ -40,7 +40,9 @@
40
40
  { slug: "nebirhos", name: "Nebirhos", desc: "teal · warm orange highlights",
41
41
  swatches: ["#0A1414","#11201F","#5EB1A6","#357770","#DD9258","#D87060","#6FBEC2","#B8D4D0"] },
42
42
  { slug: "wedisagree", name: "We Disagree", desc: "argumentative orange · subtle green",
43
- swatches: ["#14110E","#1F1A14","#DD7B40","#A8521E","#E6B872","#E26060","#6FB28A","#D8CBBC"] }
43
+ swatches: ["#14110E","#1F1A14","#DD7B40","#A8521E","#E6B872","#E26060","#6FB28A","#D8CBBC"] },
44
+ { slug: "nintendo", name: "8-bit", desc: "NES palette · Mario red · coin gold · sky cyan",
45
+ swatches: ["#181820","#22222C","#E60012","#A40009","#FBC000","#FC4438","#5BC0EB","#FCFCFC"] }
44
46
  ];
45
47
 
46
48
  const PROVIDERS = [
@@ -17,7 +17,14 @@
17
17
  .voice-replay-overlay {
18
18
  position: fixed;
19
19
  right: 24px;
20
- bottom: 24px;
20
+ /* The adjourned-bar (where Voice Replay is launched from) is
21
+ `min-height: 44px` and pinned to the bottom of the room view.
22
+ `bottom: 24px` would overlap it; lift the panel to clear the
23
+ bar with a small gap so the user always reads the bar's
24
+ [Export / Voice Replay / Convene Follow-up] actions even while
25
+ the player is open. Same offset for expanded + collapsed so
26
+ the title bar sits flush above the bottom bar when shrunk. */
27
+ bottom: 56px;
21
28
  z-index: 9300;
22
29
  width: 380px;
23
30
  max-width: calc(100vw - 32px);
@@ -56,6 +63,12 @@
56
63
  font-size: 13px;
57
64
  letter-spacing: 0;
58
65
  }
66
+ .vr-head-actions {
67
+ display: inline-flex;
68
+ align-items: center;
69
+ gap: 4px;
70
+ }
71
+ .vr-collapse,
59
72
  .vr-close {
60
73
  width: 22px;
61
74
  height: 22px;
@@ -68,13 +81,69 @@
68
81
  font-size: 11px;
69
82
  cursor: pointer;
70
83
  transition: border-color 0.12s, color 0.12s;
84
+ font-family: var(--mono);
85
+ line-height: 1;
71
86
  }
87
+ .vr-collapse { font-size: 14px; padding-bottom: 4px; }
88
+ .vr-collapse:hover,
72
89
  .vr-close:hover { border-color: var(--lime, #6FB572); color: var(--lime, #6FB572); }
73
90
 
74
91
  .vr-body {
75
92
  padding: 14px 14px 14px;
76
93
  }
77
94
 
95
+ /* Collapsed posture · the entire floating panel hides, freeing
96
+ the chat / round-table stage of any overlap. The user re-opens
97
+ by clicking the inline `.vr-inline-expand` pill that gets
98
+ slotted into the adjourned-bar's action group right next to the
99
+ Voice Replay button. Audio keeps playing in the background. */
100
+ .voice-replay-overlay.is-collapsed {
101
+ display: none;
102
+ }
103
+
104
+ /* Inline replay control group · three buttons (Next / Pause /
105
+ Expand) mounted into the adjourned-bar's action group right
106
+ after Voice Replay when the floating panel is collapsed. Each
107
+ button inherits `.ghost-btn`'s panel + hairline chrome so the
108
+ group reads as a sibling of Export / Voice Replay / Convene
109
+ Follow-up. The Expand button carries a small lime pulsing dot
110
+ so the user can locate "what's still active" at a glance. */
111
+ .vr-inline-group {
112
+ display: inline-flex;
113
+ align-items: center;
114
+ gap: 6px;
115
+ }
116
+ /* Icon-only ghost-btns · all the visual chrome (panel bg, hairline
117
+ border, mono font, uppercase, hover lime) comes from `.ghost-btn`
118
+ in index.html. We don't override font-size / color / padding so
119
+ the inline group reads as a true sibling of Export / Voice
120
+ Replay / Convene Follow-up — just with bracket-wrapped glyphs
121
+ instead of trailing labels. The Expand button gets a small lime
122
+ pulsing dot tagged on the end so the user can locate "what's
123
+ still active" at a glance. */
124
+ .vr-inline-btn .vib-icon {
125
+ display: block; /* drop the inline baseline gap so the SVG centres */
126
+ }
127
+ .vr-inline-expand {
128
+ display: inline-flex;
129
+ align-items: center;
130
+ gap: 6px;
131
+ }
132
+ .vr-inline-expand .vie-pulse {
133
+ width: 5px;
134
+ height: 5px;
135
+ border-radius: 50%;
136
+ background: var(--lime, #6FB572);
137
+ animation: vr-inline-pulse 1.6s ease-in-out infinite;
138
+ }
139
+ @keyframes vr-inline-pulse {
140
+ 0%, 100% { opacity: 1; }
141
+ 50% { opacity: 0.35; }
142
+ }
143
+ @media (prefers-reduced-motion: reduce) {
144
+ .vr-inline-expand .vie-pulse { animation: none; opacity: 0.7; }
145
+ }
146
+
78
147
  /* ─── Loading row ───
79
148
  Centered both axes so the loading state reads as a focused
80
149
  placeholder, not a left-pinned debug log. The dots row sits