privateboard 0.1.12 → 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.
- package/dist/cli.js +229 -51
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/public/agent-profile.js +0 -2
- package/public/app.js +765 -61
- package/public/home.html +609 -1
- package/public/i18n.js +2 -0
- package/public/index.html +726 -95
- package/public/themes.css +38 -0
- package/public/typing-sfx.js +58 -1
- package/public/user-settings.js +3 -1
package/public/app.js
CHANGED
|
@@ -87,11 +87,24 @@
|
|
|
87
87
|
keys: {},
|
|
88
88
|
agents: [],
|
|
89
89
|
agentsById: {},
|
|
90
|
+
/** Voice-options label cache · keyed by `<provider>:<voiceId>` →
|
|
91
|
+
* friendly label (e.g. "minimax:male-qn-qingse" → "青涩青年").
|
|
92
|
+
* Populated by a one-shot /api/voices prefetch in loadInitial.
|
|
93
|
+
* Sidebar's agent-row subtitle reads from this synchronously; a
|
|
94
|
+
* miss falls back to the raw voiceId so the row never blocks on
|
|
95
|
+
* the fetch. */
|
|
96
|
+
voiceLabels: {},
|
|
90
97
|
rooms: [],
|
|
91
98
|
currentRoomId: null,
|
|
92
99
|
currentRoom: null,
|
|
93
100
|
currentMessages: [],
|
|
94
|
-
currentMembers: [], // directors only (chair excluded)
|
|
101
|
+
currentMembers: [], // directors only (chair excluded) · ACTIVE roster
|
|
102
|
+
// Every director who's ever been in this room, including those
|
|
103
|
+
// the chair has soft-excused. Each carries `removedAt` (null =
|
|
104
|
+
// active, timestamp = excused). Used for chat-history speaker
|
|
105
|
+
// resolution + voice replay so excused directors' past messages
|
|
106
|
+
// still render their name / avatar / voice profile.
|
|
107
|
+
currentHistoricalMembers: [],
|
|
95
108
|
currentChair: null, // chair agent for the current room
|
|
96
109
|
currentQueue: [],
|
|
97
110
|
voiceQueues: {},
|
|
@@ -436,6 +449,15 @@
|
|
|
436
449
|
},
|
|
437
450
|
|
|
438
451
|
async loadInitial() {
|
|
452
|
+
// CRITICAL fetches block the dashboard's first paint — only the
|
|
453
|
+
// four queries below have data the initial render NEEDS. The
|
|
454
|
+
// `/api/voices` route is intentionally NOT in this Promise.all:
|
|
455
|
+
// its server handler can synchronously hit MiniMax + ElevenLabs
|
|
456
|
+
// cloud APIs (1-2s each), and putting it here used to delay the
|
|
457
|
+
// ENTIRE UI by the slowest of those calls. Voice labels are a
|
|
458
|
+
// sidebar-row sweetener — `voiceLabelFor()` already falls back
|
|
459
|
+
// to raw voice IDs on cache miss — so we fetch them in the
|
|
460
|
+
// background and re-render the sidebar once they arrive.
|
|
439
461
|
const [prefsRes, keysRes, agentsRes, roomsRes] = await Promise.all([
|
|
440
462
|
fetch("/api/prefs"),
|
|
441
463
|
fetch("/api/keys"),
|
|
@@ -460,6 +482,41 @@
|
|
|
460
482
|
const j = await roomsRes.json();
|
|
461
483
|
this.rooms = j.rooms || [];
|
|
462
484
|
}
|
|
485
|
+
// Fire-and-forget the voice-labels prefetch · the initial paint
|
|
486
|
+
// already finished by the time this resolves. Re-renders the
|
|
487
|
+
// agents sidebar so the friendly label ("青涩青年") swaps in for
|
|
488
|
+
// any raw voiceId rows that already mounted. Safe to discard on
|
|
489
|
+
// failure (sidebar keeps the raw id fallback).
|
|
490
|
+
void this._prefetchVoiceLabels();
|
|
491
|
+
},
|
|
492
|
+
|
|
493
|
+
/** Background prefetch for /api/voices · runs after the initial
|
|
494
|
+
* load completes so the dashboard's first paint isn't held up by
|
|
495
|
+
* the MiniMax / ElevenLabs cloud-API round-trips inside the
|
|
496
|
+
* server's `listAvailableVoices`. Triggers a sidebar re-render
|
|
497
|
+
* when labels arrive so the friendly names replace the raw voice
|
|
498
|
+
* IDs in-place. Idempotent · safe to call multiple times. */
|
|
499
|
+
async _prefetchVoiceLabels() {
|
|
500
|
+
try {
|
|
501
|
+
const res = await fetch("/api/voices");
|
|
502
|
+
if (!res || !res.ok) return;
|
|
503
|
+
const j = await res.json();
|
|
504
|
+
const list = Array.isArray(j.voices) ? j.voices : [];
|
|
505
|
+
const map = {};
|
|
506
|
+
for (const v of list) {
|
|
507
|
+
if (v && typeof v.provider === "string" && typeof v.voiceId === "string") {
|
|
508
|
+
const label = typeof v.label === "string" && v.label.trim() ? v.label.trim() : v.voiceId;
|
|
509
|
+
map[`${v.provider}:${v.voiceId}`] = label;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
this.voiceLabels = map;
|
|
513
|
+
// Re-render the agents sidebar so any rows currently showing
|
|
514
|
+
// a raw voice id (e.g. "male-qn-qingse") swap to the prettier
|
|
515
|
+
// label ("青涩青年"). Cheap idempotent paint.
|
|
516
|
+
if (typeof this.renderSidebarAgents === "function") {
|
|
517
|
+
this.renderSidebarAgents();
|
|
518
|
+
}
|
|
519
|
+
} catch { /* keep empty map · sidebar falls back to voiceId */ }
|
|
463
520
|
},
|
|
464
521
|
|
|
465
522
|
// ── Routing ───────────────────────────────────────────────
|
|
@@ -685,6 +742,11 @@
|
|
|
685
742
|
this.currentRoom = data.room;
|
|
686
743
|
this.currentMessages = data.messages || [];
|
|
687
744
|
this.currentMembers = data.members || [];
|
|
745
|
+
// Full historical roster · falls back to active members for
|
|
746
|
+
// older servers that don't ship `historicalMembers` yet.
|
|
747
|
+
this.currentHistoricalMembers = Array.isArray(data.historicalMembers)
|
|
748
|
+
? data.historicalMembers
|
|
749
|
+
: (data.members || []).map((m) => ({ ...m, removedAt: null }));
|
|
688
750
|
this.currentChair = data.chair || null;
|
|
689
751
|
this.currentQueue = data.queue || [];
|
|
690
752
|
this.currentRound = data.round || { spoken: 0, total: 0 };
|
|
@@ -859,6 +921,7 @@
|
|
|
859
921
|
this.currentRoom = null;
|
|
860
922
|
this.currentMessages = [];
|
|
861
923
|
this.currentMembers = [];
|
|
924
|
+
this.currentHistoricalMembers = [];
|
|
862
925
|
// `currentChair` is NOT reset · the chair is a structural
|
|
863
926
|
// singleton (one moderator agent in the catalog, same across
|
|
864
927
|
// every room), and the sidebar's Chair section keys off it
|
|
@@ -1145,6 +1208,22 @@
|
|
|
1145
1208
|
// meta.streaming. Skipping when a queue already exists keeps
|
|
1146
1209
|
// the SSE hot path cheap (we don't repaint per chunk).
|
|
1147
1210
|
const fresh = !this.voiceQueues[data.messageId];
|
|
1211
|
+
// Chair gavel SFX · fire ONCE when fresh chair voice starts
|
|
1212
|
+
// streaming, so the user hears the courtroom "knock-knock"
|
|
1213
|
+
// calling for attention before the chair speaks. Detection:
|
|
1214
|
+
// pull the message from currentMessages by id, check author
|
|
1215
|
+
// is the chair. Fires before enqueueVoiceChunk schedules the
|
|
1216
|
+
// first audio buffer, so the gavel overlaps only the audio
|
|
1217
|
+
// header / chunker startup (~50-100ms of inaudible buffer),
|
|
1218
|
+
// not the chair's first spoken word. Same enabled-flag as
|
|
1219
|
+
// typing/speaker-change · respects user-settings sound toggle.
|
|
1220
|
+
if (fresh && this.currentChair && window.boardroomTypingSfx
|
|
1221
|
+
&& typeof window.boardroomTypingSfx.gavel === "function") {
|
|
1222
|
+
const msg = (this.currentMessages || []).find((m) => m.id === data.messageId);
|
|
1223
|
+
if (msg && msg.authorKind === "agent" && msg.authorId === this.currentChair.id) {
|
|
1224
|
+
window.boardroomTypingSfx.gavel();
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1148
1227
|
this.enqueueVoiceChunk(roomId, data);
|
|
1149
1228
|
if (fresh) {
|
|
1150
1229
|
this.renderRoundTable();
|
|
@@ -1669,6 +1748,27 @@
|
|
|
1669
1748
|
this.currentMembers.push(byId[aid]);
|
|
1670
1749
|
}
|
|
1671
1750
|
}
|
|
1751
|
+
// Mirror the diff into historicalMembers so excused
|
|
1752
|
+
// directors stay queryable for chat-history + voice
|
|
1753
|
+
// replay lookups. Removal flips `removedAt` to now;
|
|
1754
|
+
// re-adding clears it back to null. New additions
|
|
1755
|
+
// append the snapshot from the global agents catalog.
|
|
1756
|
+
if (!Array.isArray(this.currentHistoricalMembers)) {
|
|
1757
|
+
this.currentHistoricalMembers = [];
|
|
1758
|
+
}
|
|
1759
|
+
if (removed.length > 0) {
|
|
1760
|
+
const ts = Date.now();
|
|
1761
|
+
for (const id of removed) {
|
|
1762
|
+
const entry = this.currentHistoricalMembers.find((m) => m.id === id);
|
|
1763
|
+
if (entry) entry.removedAt = ts;
|
|
1764
|
+
else if (byId[id]) this.currentHistoricalMembers.push({ ...byId[id], removedAt: ts });
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
for (const aid of added) {
|
|
1768
|
+
const entry = this.currentHistoricalMembers.find((m) => m.id === aid);
|
|
1769
|
+
if (entry) entry.removedAt = null;
|
|
1770
|
+
else if (byId[aid]) this.currentHistoricalMembers.push({ ...byId[aid], removedAt: null });
|
|
1771
|
+
}
|
|
1672
1772
|
this.renderHeader();
|
|
1673
1773
|
this.renderQueue();
|
|
1674
1774
|
// Round-table toasts · one chip per added / removed agent.
|
|
@@ -4259,6 +4359,7 @@
|
|
|
4259
4359
|
this.currentRoom = null;
|
|
4260
4360
|
this.currentMessages = [];
|
|
4261
4361
|
this.currentMembers = [];
|
|
4362
|
+
this.currentHistoricalMembers = [];
|
|
4262
4363
|
this.currentQueue = [];
|
|
4263
4364
|
this.currentBrief = null;
|
|
4264
4365
|
location.hash = "";
|
|
@@ -4729,10 +4830,27 @@
|
|
|
4729
4830
|
|
|
4730
4831
|
const renderRow = (a, opts = {}) => {
|
|
4731
4832
|
const status = "active"; // every persisted director is active in v1
|
|
4732
|
-
const time = this.relTime(a.createdAt) || "—";
|
|
4733
4833
|
const pinBtn = `
|
|
4734
4834
|
<button type="button" class="pin-toggle" title="${this.escape(a.isPinned ? this._t("sidebar_unpin") : this._t("sidebar_pin"))}" data-pin-toggle>${PIN_GLYPH}</button>
|
|
4735
4835
|
`;
|
|
4836
|
+
// Subtitle · model name + (optional) voice character.
|
|
4837
|
+
// Replaces the prior "{roleTag} · Active" pattern which carried
|
|
4838
|
+
// no information beyond what the section header already
|
|
4839
|
+
// conveyed. Model is the practical "what's this agent" tell;
|
|
4840
|
+
// voice (when set) tells the user which TTS voice they'll hear
|
|
4841
|
+
// in voice rooms.
|
|
4842
|
+
const modelLabel = this.modelLabel(a.modelV);
|
|
4843
|
+
const voiceLabel = this.voiceLabelFor(a);
|
|
4844
|
+
const subParts = [];
|
|
4845
|
+
if (modelLabel) {
|
|
4846
|
+
subParts.push(`<span class="agent-row-model">${this.escape(modelLabel)}</span>`);
|
|
4847
|
+
}
|
|
4848
|
+
if (voiceLabel) {
|
|
4849
|
+
if (subParts.length > 0) {
|
|
4850
|
+
subParts.push(`<span class="agent-row-sep">·</span>`);
|
|
4851
|
+
}
|
|
4852
|
+
subParts.push(`<span class="agent-row-voice">${this.escape(voiceLabel)}</span>`);
|
|
4853
|
+
}
|
|
4736
4854
|
// Delete moved off the sidebar row · it now lives inside the
|
|
4737
4855
|
// agent profile's ⋯ overflow menu where it's protected by the
|
|
4738
4856
|
// standard ⋯ → confirm flow. The sidebar row is the navigate-
|
|
@@ -4746,11 +4864,7 @@
|
|
|
4746
4864
|
<span class="agent-row-title">${this.escape(a.name)}</span>
|
|
4747
4865
|
${pinBtn}
|
|
4748
4866
|
</div>
|
|
4749
|
-
<div class="agent-row-subtitle">
|
|
4750
|
-
<span>${this.escape(a.roleTag || this._t("sidebar_role_director"))}</span>
|
|
4751
|
-
<span class="agent-row-sep">·</span>
|
|
4752
|
-
<span class="agent-row-status">${this.escape(this._t("sidebar_status_active"))}</span>
|
|
4753
|
-
</div>
|
|
4867
|
+
<div class="agent-row-subtitle">${subParts.join("")}</div>
|
|
4754
4868
|
</div>
|
|
4755
4869
|
</a>
|
|
4756
4870
|
</div>
|
|
@@ -5599,6 +5713,7 @@
|
|
|
5599
5713
|
this.currentRoom = null;
|
|
5600
5714
|
this.currentMessages = [];
|
|
5601
5715
|
this.currentMembers = [];
|
|
5716
|
+
this.currentHistoricalMembers = [];
|
|
5602
5717
|
this.currentQueue = [];
|
|
5603
5718
|
this.currentBrief = null;
|
|
5604
5719
|
// Clear the URL hash so re-clicking a room link works (same
|
|
@@ -6089,6 +6204,7 @@
|
|
|
6089
6204
|
this.currentRoom = null;
|
|
6090
6205
|
this.currentMessages = [];
|
|
6091
6206
|
this.currentMembers = [];
|
|
6207
|
+
this.currentHistoricalMembers = [];
|
|
6092
6208
|
this.currentQueue = [];
|
|
6093
6209
|
this.currentBrief = null;
|
|
6094
6210
|
if (/^#\/r\//.test(location.hash)) {
|
|
@@ -6163,6 +6279,7 @@
|
|
|
6163
6279
|
this.currentRoom = null;
|
|
6164
6280
|
this.currentMessages = [];
|
|
6165
6281
|
this.currentMembers = [];
|
|
6282
|
+
this.currentHistoricalMembers = [];
|
|
6166
6283
|
this.currentQueue = [];
|
|
6167
6284
|
this.currentBrief = null;
|
|
6168
6285
|
if (/^#\/r\//.test(location.hash)) {
|
|
@@ -6196,35 +6313,293 @@
|
|
|
6196
6313
|
|
|
6197
6314
|
const page = document.querySelector("[data-search-page]");
|
|
6198
6315
|
if (!page) return;
|
|
6199
|
-
// Initial paint ·
|
|
6200
|
-
//
|
|
6316
|
+
// Initial paint · Google-style two-state markup. Both the
|
|
6317
|
+
// hero (kicker / wordmark / caption) and the head row (input
|
|
6318
|
+
// + result-count meta) live in the DOM together so the
|
|
6319
|
+
// `.is-initial` ↔ `.has-results` flip is purely a CSS class
|
|
6320
|
+
// change — the input element stays put so its value / focus
|
|
6321
|
+
// / cursor survive the swap. The doc-level `[data-search-
|
|
6322
|
+
// input]` listener (~line 15486) calls `runSearch(value)` on
|
|
6323
|
+
// every input event; the clear button (~line 14577) routes
|
|
6324
|
+
// back through `runSearch("")`.
|
|
6201
6325
|
const lastQuery = this._searchLastQuery || "";
|
|
6326
|
+
const startsInResults = lastQuery.length > 0;
|
|
6327
|
+
page.className = "search-page " + (startsInResults ? "has-results" : "is-initial");
|
|
6328
|
+
// Perplexity-style hero · calm conversational tagline above
|
|
6329
|
+
// a substantial card-style input with an internal footer
|
|
6330
|
+
// toolbar (mono hint left, lime send button right). Starter
|
|
6331
|
+
// chips below give one-click into common queries. The same
|
|
6332
|
+
// input element serves both states; `.is-initial` styles
|
|
6333
|
+
// make the wrapping `.search-card` look like a standalone
|
|
6334
|
+
// card, while `.has-results` strips the card chrome and
|
|
6335
|
+
// collapses input + meta into a compact head row.
|
|
6336
|
+
// 8-bit ambient deco · scattered pixel constellation that
|
|
6337
|
+
// gives the page texture without competing with the hero.
|
|
6338
|
+
// All coordinates picked by hand to avoid the "regular
|
|
6339
|
+
// grid" feel · varied densities + 3 lime accents land
|
|
6340
|
+
// the eye softly. CSS masks the bottom edge to a fade so
|
|
6341
|
+
// it never crowds the card. Static · no animation,
|
|
6342
|
+
// pointer-events: none, aria-hidden.
|
|
6343
|
+
const BG_DECO_SVG = `
|
|
6344
|
+
<svg viewBox="0 0 800 280" preserveAspectRatio="xMidYMin slice"
|
|
6345
|
+
shape-rendering="crispEdges" aria-hidden="true">
|
|
6346
|
+
<!-- Faint pixel dots · scattered, mostly 2×2. -->
|
|
6347
|
+
<g fill="var(--line-bright, #2A2A26)">
|
|
6348
|
+
<rect x="32" y="38" width="2" height="2"/>
|
|
6349
|
+
<rect x="78" y="22" width="2" height="2"/>
|
|
6350
|
+
<rect x="118" y="62" width="2" height="2"/>
|
|
6351
|
+
<rect x="156" y="30" width="2" height="2"/>
|
|
6352
|
+
<rect x="206" y="78" width="2" height="2"/>
|
|
6353
|
+
<rect x="254" y="44" width="2" height="2"/>
|
|
6354
|
+
<rect x="298" y="92" width="2" height="2"/>
|
|
6355
|
+
<rect x="342" y="26" width="2" height="2"/>
|
|
6356
|
+
<rect x="388" y="68" width="2" height="2"/>
|
|
6357
|
+
<rect x="436" y="38" width="2" height="2"/>
|
|
6358
|
+
<rect x="486" y="86" width="2" height="2"/>
|
|
6359
|
+
<rect x="528" y="50" width="2" height="2"/>
|
|
6360
|
+
<rect x="572" y="22" width="2" height="2"/>
|
|
6361
|
+
<rect x="618" y="74" width="2" height="2"/>
|
|
6362
|
+
<rect x="664" y="42" width="2" height="2"/>
|
|
6363
|
+
<rect x="708" y="88" width="2" height="2"/>
|
|
6364
|
+
<rect x="752" y="32" width="2" height="2"/>
|
|
6365
|
+
<rect x="60" y="118" width="2" height="2"/>
|
|
6366
|
+
<rect x="146" y="142" width="2" height="2"/>
|
|
6367
|
+
<rect x="222" y="116" width="2" height="2"/>
|
|
6368
|
+
<rect x="316" y="148" width="2" height="2"/>
|
|
6369
|
+
<rect x="402" y="124" width="2" height="2"/>
|
|
6370
|
+
<rect x="488" y="158" width="2" height="2"/>
|
|
6371
|
+
<rect x="572" y="118" width="2" height="2"/>
|
|
6372
|
+
<rect x="654" y="146" width="2" height="2"/>
|
|
6373
|
+
<rect x="734" y="124" width="2" height="2"/>
|
|
6374
|
+
<rect x="92" y="180" width="2" height="2"/>
|
|
6375
|
+
<rect x="186" y="206" width="2" height="2"/>
|
|
6376
|
+
<rect x="278" y="186" width="2" height="2"/>
|
|
6377
|
+
<rect x="370" y="216" width="2" height="2"/>
|
|
6378
|
+
<rect x="462" y="194" width="2" height="2"/>
|
|
6379
|
+
<rect x="554" y="222" width="2" height="2"/>
|
|
6380
|
+
<rect x="646" y="198" width="2" height="2"/>
|
|
6381
|
+
<rect x="724" y="226" width="2" height="2"/>
|
|
6382
|
+
</g>
|
|
6383
|
+
<!-- Mid accents · 4×4 squares, lime-dim. -->
|
|
6384
|
+
<g fill="var(--lime-dim, #2D5532)">
|
|
6385
|
+
<rect x="226" y="46" width="4" height="4"/>
|
|
6386
|
+
<rect x="514" y="100" width="4" height="4"/>
|
|
6387
|
+
<rect x="694" y="170" width="4" height="4"/>
|
|
6388
|
+
</g>
|
|
6389
|
+
<!-- Pixel "plus" accents · evoke the 8-bit
|
|
6390
|
+
star/sparkle vocabulary. Lime-dim. -->
|
|
6391
|
+
<g fill="var(--lime-dim, #2D5532)">
|
|
6392
|
+
<!-- top-left plus -->
|
|
6393
|
+
<rect x="98" y="78" width="6" height="2"/>
|
|
6394
|
+
<rect x="100" y="76" width="2" height="6"/>
|
|
6395
|
+
<!-- mid-right plus -->
|
|
6396
|
+
<rect x="640" y="62" width="6" height="2"/>
|
|
6397
|
+
<rect x="642" y="60" width="2" height="6"/>
|
|
6398
|
+
<!-- lower-left plus -->
|
|
6399
|
+
<rect x="354" y="178" width="6" height="2"/>
|
|
6400
|
+
<rect x="356" y="176" width="2" height="6"/>
|
|
6401
|
+
</g>
|
|
6402
|
+
<!-- Bright accents · sparse, lime. Catch-the-eye
|
|
6403
|
+
points scattered at the visual rule-of-thirds. -->
|
|
6404
|
+
<g fill="var(--lime, #6FB572)">
|
|
6405
|
+
<rect x="170" y="98" width="3" height="3"/>
|
|
6406
|
+
<rect x="540" y="38" width="3" height="3"/>
|
|
6407
|
+
<rect x="416" y="170" width="3" height="3"/>
|
|
6408
|
+
</g>
|
|
6409
|
+
<!-- Pixel "frame" segments · short hairline brackets at
|
|
6410
|
+
the very top corners · evoke a CRT scanlines feel
|
|
6411
|
+
without a full grid. -->
|
|
6412
|
+
<g fill="var(--line-bright, #2A2A26)">
|
|
6413
|
+
<rect x="14" y="14" width="20" height="2"/>
|
|
6414
|
+
<rect x="14" y="14" width="2" height="20"/>
|
|
6415
|
+
<rect x="766" y="14" width="20" height="2"/>
|
|
6416
|
+
<rect x="784" y="14" width="2" height="20"/>
|
|
6417
|
+
</g>
|
|
6418
|
+
</svg>
|
|
6419
|
+
`;
|
|
6420
|
+
// Results-only deco · second 8-bit layer that mounts on
|
|
6421
|
+
// top of the constellation when .has-results is active.
|
|
6422
|
+
// Adds proper "search station" character: pixel antennas
|
|
6423
|
+
// anchoring the corners, scattered "+" sparkles between
|
|
6424
|
+
// them, denser dot field, and a horizon-tick floor at
|
|
6425
|
+
// the bottom of the band. CSS scanlines (declared in
|
|
6426
|
+
// index.html) sit underneath via background-image.
|
|
6427
|
+
const RESULTS_DECO_SVG = `
|
|
6428
|
+
<svg viewBox="0 0 800 130" preserveAspectRatio="xMidYMin slice"
|
|
6429
|
+
shape-rendering="crispEdges" aria-hidden="true">
|
|
6430
|
+
<!-- Left antenna · vertical mast + 2 crossbars +
|
|
6431
|
+
base tile. Reads as a pixel radio tower. -->
|
|
6432
|
+
<g fill="var(--line-bright, #2A2A26)">
|
|
6433
|
+
<rect x="22" y="40" width="2" height="74"/>
|
|
6434
|
+
<rect x="18" y="56" width="10" height="2"/>
|
|
6435
|
+
<rect x="20" y="74" width="6" height="2"/>
|
|
6436
|
+
<rect x="14" y="114" width="18" height="3"/>
|
|
6437
|
+
</g>
|
|
6438
|
+
<!-- Left antenna blink tip · static lime accent. -->
|
|
6439
|
+
<rect x="22" y="36" width="2" height="2" fill="var(--lime, #6FB572)"/>
|
|
6440
|
+
<!-- Right antenna · mirror of the left. -->
|
|
6441
|
+
<g fill="var(--line-bright, #2A2A26)">
|
|
6442
|
+
<rect x="776" y="44" width="2" height="70"/>
|
|
6443
|
+
<rect x="772" y="60" width="10" height="2"/>
|
|
6444
|
+
<rect x="774" y="78" width="6" height="2"/>
|
|
6445
|
+
<rect x="768" y="114" width="18" height="3"/>
|
|
6446
|
+
</g>
|
|
6447
|
+
<rect x="776" y="40" width="2" height="2" fill="var(--lime, #6FB572)"/>
|
|
6448
|
+
<!-- Pixel "+" sparkles · scattered in the central
|
|
6449
|
+
band between the antennas. Reads as scanner
|
|
6450
|
+
pings. -->
|
|
6451
|
+
<g fill="var(--lime-dim, #2D5532)">
|
|
6452
|
+
<rect x="138" y="44" width="6" height="2"/>
|
|
6453
|
+
<rect x="140" y="42" width="2" height="6"/>
|
|
6454
|
+
<rect x="324" y="68" width="6" height="2"/>
|
|
6455
|
+
<rect x="326" y="66" width="2" height="6"/>
|
|
6456
|
+
<rect x="498" y="34" width="6" height="2"/>
|
|
6457
|
+
<rect x="500" y="32" width="2" height="6"/>
|
|
6458
|
+
<rect x="612" y="86" width="6" height="2"/>
|
|
6459
|
+
<rect x="614" y="84" width="2" height="6"/>
|
|
6460
|
+
<rect x="220" y="92" width="6" height="2"/>
|
|
6461
|
+
<rect x="222" y="90" width="2" height="6"/>
|
|
6462
|
+
<rect x="700" y="50" width="6" height="2"/>
|
|
6463
|
+
<rect x="702" y="48" width="2" height="6"/>
|
|
6464
|
+
</g>
|
|
6465
|
+
<!-- Dense pixel-dot field · layered on top of the
|
|
6466
|
+
existing constellation to thicken the header. -->
|
|
6467
|
+
<g fill="var(--line-bright, #2A2A26)">
|
|
6468
|
+
<rect x="62" y="28" width="2" height="2"/>
|
|
6469
|
+
<rect x="106" y="78" width="2" height="2"/>
|
|
6470
|
+
<rect x="170" y="34" width="2" height="2"/>
|
|
6471
|
+
<rect x="248" y="56" width="2" height="2"/>
|
|
6472
|
+
<rect x="288" y="22" width="2" height="2"/>
|
|
6473
|
+
<rect x="370" y="50" width="2" height="2"/>
|
|
6474
|
+
<rect x="412" y="92" width="2" height="2"/>
|
|
6475
|
+
<rect x="468" y="68" width="2" height="2"/>
|
|
6476
|
+
<rect x="544" y="84" width="2" height="2"/>
|
|
6477
|
+
<rect x="588" y="44" width="2" height="2"/>
|
|
6478
|
+
<rect x="668" y="76" width="2" height="2"/>
|
|
6479
|
+
<rect x="722" y="32" width="2" height="2"/>
|
|
6480
|
+
<rect x="84" y="98" width="2" height="2"/>
|
|
6481
|
+
<rect x="262" y="100" width="2" height="2"/>
|
|
6482
|
+
</g>
|
|
6483
|
+
<!-- Bright lime accent dots · 2 only, at rule-of-
|
|
6484
|
+
thirds positions. Catches the eye. -->
|
|
6485
|
+
<g fill="var(--lime, #6FB572)">
|
|
6486
|
+
<rect x="266" y="40" width="3" height="3"/>
|
|
6487
|
+
<rect x="566" y="74" width="3" height="3"/>
|
|
6488
|
+
</g>
|
|
6489
|
+
<!-- Horizon ticks · faint dashed pixel line near
|
|
6490
|
+
the bottom edge of the band. Acts as visual
|
|
6491
|
+
"floor" the antennas plant on. -->
|
|
6492
|
+
<g fill="var(--line, #1A1A18)">
|
|
6493
|
+
<rect x="40" y="122" width="6" height="1"/>
|
|
6494
|
+
<rect x="60" y="122" width="2" height="1"/>
|
|
6495
|
+
<rect x="76" y="122" width="6" height="1"/>
|
|
6496
|
+
<rect x="96" y="122" width="2" height="1"/>
|
|
6497
|
+
<rect x="112" y="122" width="6" height="1"/>
|
|
6498
|
+
<rect x="132" y="122" width="2" height="1"/>
|
|
6499
|
+
<rect x="148" y="122" width="6" height="1"/>
|
|
6500
|
+
<rect x="168" y="122" width="2" height="1"/>
|
|
6501
|
+
<rect x="184" y="122" width="6" height="1"/>
|
|
6502
|
+
<rect x="204" y="122" width="2" height="1"/>
|
|
6503
|
+
<rect x="220" y="122" width="6" height="1"/>
|
|
6504
|
+
<rect x="240" y="122" width="2" height="1"/>
|
|
6505
|
+
<rect x="256" y="122" width="6" height="1"/>
|
|
6506
|
+
<rect x="276" y="122" width="2" height="1"/>
|
|
6507
|
+
<rect x="292" y="122" width="6" height="1"/>
|
|
6508
|
+
<rect x="312" y="122" width="2" height="1"/>
|
|
6509
|
+
<rect x="328" y="122" width="6" height="1"/>
|
|
6510
|
+
<rect x="348" y="122" width="2" height="1"/>
|
|
6511
|
+
<rect x="364" y="122" width="6" height="1"/>
|
|
6512
|
+
<rect x="384" y="122" width="2" height="1"/>
|
|
6513
|
+
<rect x="400" y="122" width="6" height="1"/>
|
|
6514
|
+
<rect x="420" y="122" width="2" height="1"/>
|
|
6515
|
+
<rect x="436" y="122" width="6" height="1"/>
|
|
6516
|
+
<rect x="456" y="122" width="2" height="1"/>
|
|
6517
|
+
<rect x="472" y="122" width="6" height="1"/>
|
|
6518
|
+
<rect x="492" y="122" width="2" height="1"/>
|
|
6519
|
+
<rect x="508" y="122" width="6" height="1"/>
|
|
6520
|
+
<rect x="528" y="122" width="2" height="1"/>
|
|
6521
|
+
<rect x="544" y="122" width="6" height="1"/>
|
|
6522
|
+
<rect x="564" y="122" width="2" height="1"/>
|
|
6523
|
+
<rect x="580" y="122" width="6" height="1"/>
|
|
6524
|
+
<rect x="600" y="122" width="2" height="1"/>
|
|
6525
|
+
<rect x="616" y="122" width="6" height="1"/>
|
|
6526
|
+
<rect x="636" y="122" width="2" height="1"/>
|
|
6527
|
+
<rect x="652" y="122" width="6" height="1"/>
|
|
6528
|
+
<rect x="672" y="122" width="2" height="1"/>
|
|
6529
|
+
<rect x="688" y="122" width="6" height="1"/>
|
|
6530
|
+
<rect x="708" y="122" width="2" height="1"/>
|
|
6531
|
+
<rect x="724" y="122" width="6" height="1"/>
|
|
6532
|
+
<rect x="744" y="122" width="2" height="1"/>
|
|
6533
|
+
<rect x="760" y="122" width="6" height="1"/>
|
|
6534
|
+
</g>
|
|
6535
|
+
</svg>
|
|
6536
|
+
`;
|
|
6202
6537
|
page.innerHTML = `
|
|
6203
|
-
|
|
6204
|
-
|
|
6205
|
-
|
|
6206
|
-
|
|
6207
|
-
|
|
6538
|
+
<!-- 8-bit ambient deco · top-of-page background overlay
|
|
6539
|
+
for the is-initial state. Stays visible (dimmer)
|
|
6540
|
+
in has-results as the base atmosphere layer. -->
|
|
6541
|
+
<div class="search-bg-deco">${BG_DECO_SVG}</div>
|
|
6542
|
+
<!-- Results-only deco · second layer with antennas +
|
|
6543
|
+
sparkles + horizon ticks + CSS scanlines (in CSS).
|
|
6544
|
+
Hidden in is-initial via opacity. -->
|
|
6545
|
+
<div class="search-results-deco">${RESULTS_DECO_SVG}</div>
|
|
6546
|
+
<!-- Hero · longer wordmark + mono subline. Only visible
|
|
6547
|
+
in .is-initial · faded + collapsed via CSS once
|
|
6548
|
+
.has-results lands. -->
|
|
6549
|
+
<div class="search-hero">
|
|
6550
|
+
<h1 class="search-hero-title">Search every conversation</h1>
|
|
6551
|
+
<p class="search-hero-sub">across every room · keyword · message body · room name</p>
|
|
6208
6552
|
</div>
|
|
6209
|
-
|
|
6210
|
-
|
|
6211
|
-
|
|
6212
|
-
|
|
6213
|
-
|
|
6214
|
-
|
|
6215
|
-
|
|
6216
|
-
|
|
6217
|
-
|
|
6218
|
-
|
|
6219
|
-
|
|
6220
|
-
|
|
6553
|
+
<!-- Card · is-initial = standalone framed input with
|
|
6554
|
+
internal toolbar; has-results = flex row with the
|
|
6555
|
+
meta beside it, toolbar hidden. -->
|
|
6556
|
+
<div class="search-card${lastQuery ? "" : " is-empty"}" data-search-card>
|
|
6557
|
+
<div class="search-input-wrap">
|
|
6558
|
+
<span class="search-input-icon" aria-hidden="true">
|
|
6559
|
+
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
|
|
6560
|
+
<circle cx="7" cy="7" r="4.5"/>
|
|
6561
|
+
<line x1="10.3" y1="10.3" x2="13.5" y2="13.5"/>
|
|
6562
|
+
</svg>
|
|
6563
|
+
</span>
|
|
6564
|
+
<input type="text" class="search-input" data-search-input
|
|
6565
|
+
placeholder="Find a keyword, decision, or name across rooms"
|
|
6566
|
+
value="${this.escape(lastQuery)}"
|
|
6567
|
+
spellcheck="false"
|
|
6568
|
+
autocomplete="off">
|
|
6569
|
+
<button type="button" class="search-input-clear" data-search-clear aria-label="Clear" title="Clear">✕</button>
|
|
6570
|
+
</div>
|
|
6571
|
+
<!-- Sort filter · only visible in .has-results. Toggle
|
|
6572
|
+
between newest-first (default) and oldest-first.
|
|
6573
|
+
Click handler in the doc-level delegate updates
|
|
6574
|
+
app._searchSort and re-renders from the cached
|
|
6575
|
+
result list (no re-fetch). -->
|
|
6576
|
+
<div class="search-results-sort" data-search-sort>
|
|
6577
|
+
<span class="srs-label">sort</span>
|
|
6578
|
+
<button type="button" data-search-sort-by="newest" class="active">Newest</button>
|
|
6579
|
+
<button type="button" data-search-sort-by="oldest">Oldest</button>
|
|
6580
|
+
</div>
|
|
6581
|
+
<span class="search-results-meta" data-search-results-meta></span>
|
|
6582
|
+
<div class="search-input-toolbar">
|
|
6583
|
+
<span class="search-toolbar-hint">keyword · message body · room name</span>
|
|
6584
|
+
</div>
|
|
6221
6585
|
</div>
|
|
6222
|
-
|
|
6223
|
-
|
|
6224
|
-
|
|
6225
|
-
|
|
6586
|
+
<!-- Static starter chips · one click pre-fills the input
|
|
6587
|
+
and triggers the search. Hidden once the user types
|
|
6588
|
+
anything (.has-results). System UI English-only. -->
|
|
6589
|
+
<div class="search-starters" data-search-starters>
|
|
6590
|
+
<span class="search-starters-label">try</span>
|
|
6591
|
+
<button type="button" class="search-starter" data-search-starter="decision">decision</button>
|
|
6592
|
+
<button type="button" class="search-starter" data-search-starter="next step">next step</button>
|
|
6593
|
+
<button type="button" class="search-starter" data-search-starter="risk">risk</button>
|
|
6594
|
+
<button type="button" class="search-starter" data-search-starter="ship">ship</button>
|
|
6226
6595
|
</div>
|
|
6596
|
+
<div class="search-results" data-search-results></div>
|
|
6227
6597
|
`;
|
|
6598
|
+
// Initialise the sort default · defaults to "newest"
|
|
6599
|
+
// (most recent matches first). The chip group reads from
|
|
6600
|
+
// this on every render to set the active state.
|
|
6601
|
+
if (this._searchSort !== "oldest") this._searchSort = "newest";
|
|
6602
|
+
this._refreshSortChips();
|
|
6228
6603
|
const inputEl = page.querySelector("[data-search-input]");
|
|
6229
6604
|
if (inputEl) {
|
|
6230
6605
|
// Defer focus so the layout settles + view is visible.
|
|
@@ -6237,6 +6612,18 @@
|
|
|
6237
6612
|
}
|
|
6238
6613
|
},
|
|
6239
6614
|
|
|
6615
|
+
/** Flip the page-level state class. `is-initial` mounts the
|
|
6616
|
+
* vertical-centre hero; `has-results` collapses the hero and
|
|
6617
|
+
* shrinks the input into a head row beside the meta. Called
|
|
6618
|
+
* from runSearch whenever the query crosses the empty
|
|
6619
|
+
* threshold (and on cold mount in renderSearchPage). */
|
|
6620
|
+
_setSearchPageState(state) {
|
|
6621
|
+
const page = document.querySelector("[data-search-page]");
|
|
6622
|
+
if (!page) return;
|
|
6623
|
+
page.classList.toggle("is-initial", state === "initial");
|
|
6624
|
+
page.classList.toggle("has-results", state !== "initial");
|
|
6625
|
+
},
|
|
6626
|
+
|
|
6240
6627
|
/** Debounced search · 200ms after last keystroke we hit
|
|
6241
6628
|
* /api/search and re-render the results panel. Re-entrancy is
|
|
6242
6629
|
* guarded by a sequence number so a slow earlier request
|
|
@@ -6250,12 +6637,31 @@
|
|
|
6250
6637
|
}
|
|
6251
6638
|
const seq = (this._searchSeq = (this._searchSeq || 0) + 1);
|
|
6252
6639
|
const target = document.querySelector("[data-search-results]");
|
|
6640
|
+
const metaEl = document.querySelector("[data-search-results-meta]");
|
|
6641
|
+
// Keep the card's empty-state class in sync with the live
|
|
6642
|
+
// query · the CSS class hides the trailing ✕ when the
|
|
6643
|
+
// input is genuinely empty so the hero's right edge stays
|
|
6644
|
+
// clean before the user has typed anything.
|
|
6645
|
+
const card = document.querySelector("[data-search-card]");
|
|
6646
|
+
if (card) card.classList.toggle("is-empty", q.length === 0);
|
|
6253
6647
|
if (!target) return;
|
|
6254
6648
|
if (q.length === 0) {
|
|
6255
|
-
|
|
6649
|
+
// Empty query · snap back to the hero (initial state),
|
|
6650
|
+
// clear the results list + meta. The CSS hides both via
|
|
6651
|
+
// `.is-initial`; we still wipe innerHTML so a later
|
|
6652
|
+
// .has-results flip doesn't briefly show stale rows.
|
|
6653
|
+
this._setSearchPageState("initial");
|
|
6654
|
+
target.innerHTML = "";
|
|
6655
|
+
if (metaEl) metaEl.textContent = "";
|
|
6256
6656
|
return;
|
|
6257
6657
|
}
|
|
6658
|
+
// Non-empty query · flip to results state (CSS fades the
|
|
6659
|
+
// hero out, shrinks the input into the head row) and show
|
|
6660
|
+
// a small "searching…" placeholder while the fetch is in
|
|
6661
|
+
// flight. The meta line takes a beat to populate.
|
|
6662
|
+
this._setSearchPageState("results");
|
|
6258
6663
|
target.innerHTML = `<div class="search-empty"><span class="search-empty-kicker">// searching</span><div class="search-empty-msg">…</div></div>`;
|
|
6664
|
+
if (metaEl) metaEl.textContent = "";
|
|
6259
6665
|
this._searchDebounceTimer = setTimeout(async () => {
|
|
6260
6666
|
try {
|
|
6261
6667
|
const r = await fetch("/api/search?q=" + encodeURIComponent(q));
|
|
@@ -6270,34 +6676,74 @@
|
|
|
6270
6676
|
}, 200);
|
|
6271
6677
|
},
|
|
6272
6678
|
|
|
6273
|
-
/** Render the search results list · flat ordered
|
|
6274
|
-
*
|
|
6275
|
-
*
|
|
6276
|
-
*
|
|
6277
|
-
*
|
|
6278
|
-
*
|
|
6679
|
+
/** Render the search results list · flat ordered list with
|
|
6680
|
+
* Google-style 3-line rows (mono source breadcrumb → sans
|
|
6681
|
+
* link title → sans snippet body). Per-row chrome is defined
|
|
6682
|
+
* in `renderSearchResultRow`. The sort order ("newest" /
|
|
6683
|
+
* "oldest") is read from `app._searchSort` (default
|
|
6684
|
+
* "newest") and re-applied client-side by `_applySearchSort`
|
|
6685
|
+
* whenever the user clicks a sort chip — no re-fetch. */
|
|
6279
6686
|
renderSearchResults(results, query) {
|
|
6280
6687
|
const target = document.querySelector("[data-search-results]");
|
|
6688
|
+
const metaEl = document.querySelector("[data-search-results-meta]");
|
|
6281
6689
|
if (!target) return;
|
|
6690
|
+
// Cache the raw response so the sort chip click can re-
|
|
6691
|
+
// render without hitting the server again. Also store the
|
|
6692
|
+
// query so the row builder knows what to highlight.
|
|
6693
|
+
this._searchLastResults = Array.isArray(results) ? results.slice() : [];
|
|
6694
|
+
this._searchLastQueryRendered = query || "";
|
|
6282
6695
|
if (!results || results.length === 0) {
|
|
6696
|
+
if (metaEl) metaEl.textContent = `0 matches`;
|
|
6283
6697
|
target.innerHTML = `<div class="search-empty"><span class="search-empty-kicker">// no matches</span><div class="search-empty-msg">No messages match "${this.escape(query)}". Try a shorter or different term.</div></div>`;
|
|
6284
6698
|
return;
|
|
6285
6699
|
}
|
|
6286
|
-
const
|
|
6287
|
-
const
|
|
6288
|
-
|
|
6289
|
-
|
|
6290
|
-
|
|
6291
|
-
|
|
6700
|
+
const sorted = this._applySearchSort(results);
|
|
6701
|
+
const roomSet = new Set(sorted.map((r) => r.roomId));
|
|
6702
|
+
if (metaEl) {
|
|
6703
|
+
metaEl.textContent =
|
|
6704
|
+
`${sorted.length} match${sorted.length === 1 ? "" : "es"} · ` +
|
|
6705
|
+
`${roomSet.size} room${roomSet.size === 1 ? "" : "s"}`;
|
|
6706
|
+
}
|
|
6707
|
+
const rows = sorted.map((hit) => this.renderSearchResultRow(hit, query)).join("");
|
|
6708
|
+
target.innerHTML = `<ul class="search-results-list">${rows}</ul>`;
|
|
6709
|
+
},
|
|
6710
|
+
|
|
6711
|
+
/** Sort a results array by createdAt according to the current
|
|
6712
|
+
* `app._searchSort` setting. Returns a NEW array so the
|
|
6713
|
+
* cached `_searchLastResults` stays in original order. */
|
|
6714
|
+
_applySearchSort(results) {
|
|
6715
|
+
const order = this._searchSort === "oldest" ? "oldest" : "newest";
|
|
6716
|
+
const sorted = (results || []).slice();
|
|
6717
|
+
sorted.sort((a, b) => {
|
|
6718
|
+
const aT = (a && a.createdAt) || 0;
|
|
6719
|
+
const bT = (b && b.createdAt) || 0;
|
|
6720
|
+
return order === "oldest" ? aT - bT : bT - aT;
|
|
6721
|
+
});
|
|
6722
|
+
return sorted;
|
|
6723
|
+
},
|
|
6724
|
+
|
|
6725
|
+
/** Sync the active state on the sort chips so the lit button
|
|
6726
|
+
* reflects `app._searchSort`. Called from the chip click
|
|
6727
|
+
* handler + on initial mount. */
|
|
6728
|
+
_refreshSortChips() {
|
|
6729
|
+
const order = this._searchSort === "oldest" ? "oldest" : "newest";
|
|
6730
|
+
document.querySelectorAll("[data-search-sort-by]").forEach((btn) => {
|
|
6731
|
+
btn.classList.toggle("active", btn.getAttribute("data-search-sort-by") === order);
|
|
6732
|
+
});
|
|
6292
6733
|
},
|
|
6293
6734
|
|
|
6294
|
-
/** Build one result row ·
|
|
6295
|
-
*
|
|
6296
|
-
*
|
|
6297
|
-
*
|
|
6298
|
-
*
|
|
6299
|
-
*
|
|
6300
|
-
*
|
|
6735
|
+
/** Build one Google-style result row · three stacked text
|
|
6736
|
+
* lines:
|
|
6737
|
+
* 1. Source breadcrumb (mono caption, faint) · author name
|
|
6738
|
+
* + time-ago — the "URL row" equivalent.
|
|
6739
|
+
* 2. Title (sans link, lime on hover) · the room subject
|
|
6740
|
+
* (truncated). Clicking jumps to `#/r/<id>?m=<mid>&q=
|
|
6741
|
+
* <q>` exactly like the previous flat-list row.
|
|
6742
|
+
* 3. Snippet (sans body, soft tone) · 2-line clamp of the
|
|
6743
|
+
* message context with `<mark>` highlight on the
|
|
6744
|
+
* matched keyword.
|
|
6745
|
+
* Snippet window (~110 lead, ~180 trail) is unchanged from
|
|
6746
|
+
* the prior implementation — the change is purely visual. */
|
|
6301
6747
|
renderSearchResultRow(hit, query) {
|
|
6302
6748
|
const body = hit.body || "";
|
|
6303
6749
|
const offset = Math.max(0, hit.matchOffset || 0);
|
|
@@ -6316,19 +6762,28 @@
|
|
|
6316
6762
|
`<mark>${this.escape(flat(matched))}</mark>` +
|
|
6317
6763
|
this.escape(flat(after) + suffix);
|
|
6318
6764
|
const roomTitle = (hit.roomTitle || "Untitled room").trim() || "Untitled room";
|
|
6765
|
+
const author = (hit.authorName || "").trim() || "Director";
|
|
6766
|
+
const timeAgo = hit.createdAt ? this.relTime(hit.createdAt) : "";
|
|
6319
6767
|
const qParam = query ? `&q=${encodeURIComponent(query)}` : "";
|
|
6768
|
+
// Source breadcrumb · author + relative time. No "from"
|
|
6769
|
+
// label · room title is now the prominent title line (where
|
|
6770
|
+
// the user navigates to), so the breadcrumb only needs to
|
|
6771
|
+
// identify the speaker + when.
|
|
6772
|
+
const sourceLine =
|
|
6773
|
+
`<span class="sr-source-author">${this.escape(author)}</span>` +
|
|
6774
|
+
(timeAgo
|
|
6775
|
+
? `<span class="sr-source-sep">·</span><span class="sr-source-time">${this.escape(timeAgo)}</span>`
|
|
6776
|
+
: "");
|
|
6320
6777
|
return `
|
|
6321
6778
|
<li>
|
|
6322
6779
|
<a href="#/r/${this.escape(hit.roomId)}?m=${this.escape(hit.messageId)}${qParam}"
|
|
6323
|
-
class="
|
|
6780
|
+
class="sr-row"
|
|
6324
6781
|
data-search-jump-room="${this.escape(hit.roomId)}"
|
|
6325
6782
|
data-search-jump-msg="${this.escape(hit.messageId)}"
|
|
6326
6783
|
data-search-jump-q="${this.escape(query || "")}">
|
|
6327
|
-
<div class="
|
|
6328
|
-
<div class="
|
|
6329
|
-
|
|
6330
|
-
<span class="search-row-source-room">${this.escape(roomTitle)}</span>
|
|
6331
|
-
</div>
|
|
6784
|
+
<div class="sr-source">${sourceLine}</div>
|
|
6785
|
+
<div class="sr-title">${this.escape(roomTitle)}</div>
|
|
6786
|
+
<div class="sr-snippet">${snippet}</div>
|
|
6332
6787
|
</a>
|
|
6333
6788
|
</li>
|
|
6334
6789
|
`;
|
|
@@ -7177,6 +7632,180 @@
|
|
|
7177
7632
|
* tune live as a slim toolbar inside its bottom edge so the page
|
|
7178
7633
|
* has one clear gravitational centre, not three competing form
|
|
7179
7634
|
* fields. */
|
|
7635
|
+
/** 8-bit ambient backdrop · same vocabulary as the Search page's
|
|
7636
|
+
* `.search-bg-deco` (crispEdges pixel motifs, lime accents) but
|
|
7637
|
+
* scene-tuned for each composer:
|
|
7638
|
+
* · room → mini boardroom (pixel table + 4 chair silhouettes)
|
|
7639
|
+
* · agent → row of pixel character heads w/ speech bubbles
|
|
7640
|
+
* Constellation dots + corner brackets are shared. Returns plain
|
|
7641
|
+
* SVG markup ready to drop into `.cmp-bg-deco`. */
|
|
7642
|
+
composerBgDecoSvg(scene) {
|
|
7643
|
+
// Helper · stagger animation-delays so siblings don't pulse in
|
|
7644
|
+
// lockstep. Returns a string like `style="animation-delay: 1.2s"`.
|
|
7645
|
+
// The pseudo-random offset is index-based so it's deterministic
|
|
7646
|
+
// (no flicker between renders).
|
|
7647
|
+
const _delay = (i, base) => `style="animation-delay: ${(((i * 137) % 100) / 100 * base).toFixed(2)}s"`;
|
|
7648
|
+
// Constellation dots + lime accents + corner brackets · shared
|
|
7649
|
+
// ambient "8-bit sky" the user singled out as the visual goal.
|
|
7650
|
+
// Each dot animates with `deco-twinkle` keyframes for a slow
|
|
7651
|
+
// opacity blink, staggered by index so the field shimmers
|
|
7652
|
+
// organically instead of pulsing as a single beat.
|
|
7653
|
+
const scatterDots = [
|
|
7654
|
+
[32, 38], [78, 22], [118, 62], [156, 30], [206, 78], [254, 44],
|
|
7655
|
+
[298, 92], [342, 26], [388, 68], [436, 38], [486, 86], [528, 50],
|
|
7656
|
+
[572, 22], [618, 74], [664, 42], [708, 88], [752, 32], [60, 200],
|
|
7657
|
+
[186, 216], [278, 196], [554, 222], [646, 198], [734, 226],
|
|
7658
|
+
];
|
|
7659
|
+
const limeAccents = [
|
|
7660
|
+
// Dropped the centre-lower lime dot (416, 170) — it sat
|
|
7661
|
+
// directly behind the H1 prompt and read as a typo.
|
|
7662
|
+
[170, 98], [540, 38],
|
|
7663
|
+
];
|
|
7664
|
+
const scatter = `
|
|
7665
|
+
<g fill="var(--line-bright, #2A2A26)">
|
|
7666
|
+
${scatterDots.map(([x, y], i) =>
|
|
7667
|
+
`<rect class="deco-twinkle" ${_delay(i, 4.5)} x="${x}" y="${y}" width="2" height="2"/>`
|
|
7668
|
+
).join("")}
|
|
7669
|
+
</g>
|
|
7670
|
+
<g fill="var(--lime, #6FB572)">
|
|
7671
|
+
${limeAccents.map(([x, y], i) =>
|
|
7672
|
+
`<rect class="deco-shine" ${_delay(i + 7, 2.8)} x="${x}" y="${y}" width="3" height="3"/>`
|
|
7673
|
+
).join("")}
|
|
7674
|
+
</g>
|
|
7675
|
+
<g fill="var(--line-bright, #2A2A26)">
|
|
7676
|
+
<rect x="14" y="14" width="20" height="2"/>
|
|
7677
|
+
<rect x="14" y="14" width="2" height="20"/>
|
|
7678
|
+
<rect x="766" y="14" width="20" height="2"/>
|
|
7679
|
+
<rect x="784" y="14" width="2" height="20"/>
|
|
7680
|
+
</g>
|
|
7681
|
+
`;
|
|
7682
|
+
// Scene-specific MINI motifs · small scattered 8-bit glyphs that
|
|
7683
|
+
// theme the constellation without occupying the centre stage.
|
|
7684
|
+
// Replaces the previous big centred tableau (table + chairs /
|
|
7685
|
+
// row of character heads) — the user wanted ambient dots /
|
|
7686
|
+
// sparkles tinted with each composer's flavour, not a literal
|
|
7687
|
+
// scene in the hero band. Each motif is ≤ 14×14 px so the band
|
|
7688
|
+
// still reads as scatter, not a feature illustration.
|
|
7689
|
+
let motif = "";
|
|
7690
|
+
if (scene === "room") {
|
|
7691
|
+
// Room scene · tiny pixel chairs + mics + plus-sparkles + a
|
|
7692
|
+
// few pixel "speech-mark" pairs (boardroom vocabulary). All
|
|
7693
|
+
// in the warm wood / cyan moderator palette so the scatter
|
|
7694
|
+
// tints toward "meeting" without ever forming a tableau.
|
|
7695
|
+
// Each group carries an animation class · `deco-bob` for
|
|
7696
|
+
// chairs, `deco-spark` for "+" glyphs, `deco-twinkle` for
|
|
7697
|
+
// quote dots. Inline animation-delays stagger across siblings
|
|
7698
|
+
// so the field never pulses in lockstep.
|
|
7699
|
+
// Center-lower zone (roughly x ∈ [280, 520], y > 140) is
|
|
7700
|
+
// where the H1 "What's on your mind today?" prompt lands.
|
|
7701
|
+
// Cleared of motif elements so the chairs / mics / sparks /
|
|
7702
|
+
// quote dots never collide with the title text · the deco
|
|
7703
|
+
// stays visible only at the periphery + along the top band.
|
|
7704
|
+
// Same hygiene pass as the agent composer's motif.
|
|
7705
|
+
const chairs = [
|
|
7706
|
+
{ x: 108, y: 138, fill: "#7A5230" },
|
|
7707
|
+
{ x: 372, y: 46, fill: "#7A5230" },
|
|
7708
|
+
{ x: 678, y: 148, fill: "#7A5230" },
|
|
7709
|
+
];
|
|
7710
|
+
const sparks = [
|
|
7711
|
+
[98, 78], [640, 62], [588, 156],
|
|
7712
|
+
];
|
|
7713
|
+
const quotes = [
|
|
7714
|
+
[148, 110], [152, 110], [716, 98], [720, 98],
|
|
7715
|
+
];
|
|
7716
|
+
motif = `
|
|
7717
|
+
<g shape-rendering="crispEdges">
|
|
7718
|
+
<!-- Mini chair silhouettes (3 wood + 1 cyan moderator) -->
|
|
7719
|
+
${chairs.map((c, i) => `
|
|
7720
|
+
<g class="deco-bob" ${_delay(i + 3, 3.2)} fill="${c.fill}">
|
|
7721
|
+
<rect x="${c.x}" y="${c.y + 10}" width="6" height="2"/>
|
|
7722
|
+
<rect x="${c.x}" y="${c.y}" width="2" height="10"/>
|
|
7723
|
+
<rect x="${c.x + 6}" y="${c.y}" width="2" height="10"/>
|
|
7724
|
+
</g>
|
|
7725
|
+
`).join("")}
|
|
7726
|
+
<!-- Tiny microphones · static (small, would feel busy).
|
|
7727
|
+
Removed the (296, 206) centre-lower mic and the
|
|
7728
|
+
(244, 216) cyan moderator chair — both sat in the
|
|
7729
|
+
title's footprint and read as typos behind the
|
|
7730
|
+
prompt. -->
|
|
7731
|
+
<g fill="#8E8B83">
|
|
7732
|
+
<rect x="226" y="46" width="3" height="2"/>
|
|
7733
|
+
<rect x="227" y="42" width="1" height="4"/>
|
|
7734
|
+
<rect x="514" y="100" width="3" height="2"/>
|
|
7735
|
+
<rect x="515" y="96" width="1" height="4"/>
|
|
7736
|
+
</g>
|
|
7737
|
+
<!-- Wood-tone "+" sparkles · scale-pulse animation -->
|
|
7738
|
+
${sparks.map(([x, y], i) => `
|
|
7739
|
+
<g class="deco-spark" ${_delay(i + 11, 2.4)} fill="var(--amber-dim, #5C3A1F)">
|
|
7740
|
+
<rect x="${x}" y="${y + 2}" width="6" height="2"/>
|
|
7741
|
+
<rect x="${x + 2}" y="${y}" width="2" height="6"/>
|
|
7742
|
+
</g>
|
|
7743
|
+
`).join("")}
|
|
7744
|
+
<!-- Pixel "quote marks" · 2-dot pairs, twinkle in sync -->
|
|
7745
|
+
${quotes.map(([x, y], i) =>
|
|
7746
|
+
`<rect class="deco-twinkle" ${_delay(i + 17, 3.5)} x="${x}" y="${y}" width="2" height="2" fill="var(--lime-dim, #2D5532)"/>`
|
|
7747
|
+
).join("")}
|
|
7748
|
+
</g>
|
|
7749
|
+
`;
|
|
7750
|
+
} else if (scene === "agent") {
|
|
7751
|
+
// Agent scene · tiny pixel character heads (4×4) + mini
|
|
7752
|
+
// speech bubbles (5×3) + lime sparkles · evokes "cast / new
|
|
7753
|
+
// persona" without ever forming a centre tableau. Heads are
|
|
7754
|
+
// small enough to read as constellation, not as portraits.
|
|
7755
|
+
// Heads bob, bubbles blink in / out, sparkles pulse.
|
|
7756
|
+
// Center-lower zone (roughly x ∈ [280, 520], y > 140) is
|
|
7757
|
+
// where the H1 "What do you want to build?" prompt lands.
|
|
7758
|
+
// Cleared of motif elements so the heads / bubbles / sparks
|
|
7759
|
+
// never collide with the title text · the deco stays
|
|
7760
|
+
// visible only at the periphery + along the top band.
|
|
7761
|
+
const heads = [
|
|
7762
|
+
[124, 48], [498, 64], [676, 178], [218, 200],
|
|
7763
|
+
];
|
|
7764
|
+
const bubbles = [
|
|
7765
|
+
[170, 68], [362, 92], [552, 46], [612, 206],
|
|
7766
|
+
];
|
|
7767
|
+
const ideaSparks = [
|
|
7768
|
+
[68, 118], [724, 138], [84, 186],
|
|
7769
|
+
];
|
|
7770
|
+
motif = `
|
|
7771
|
+
<g shape-rendering="crispEdges">
|
|
7772
|
+
<!-- Mini character heads · 4×4 face + 1px hat band -->
|
|
7773
|
+
${heads.map(([x, y], i) => `
|
|
7774
|
+
<g class="deco-bob" ${_delay(i + 21, 3.0)}>
|
|
7775
|
+
<rect x="${x}" y="${y}" width="4" height="4" fill="#D8A878"/>
|
|
7776
|
+
<rect x="${x}" y="${y}" width="4" height="1" fill="#5C3A1F"/>
|
|
7777
|
+
</g>
|
|
7778
|
+
`).join("")}
|
|
7779
|
+
<!-- Tiny speech bubbles · 10×6 pill with 1-pixel tail · blink -->
|
|
7780
|
+
${bubbles.map(([x, y], i) => `
|
|
7781
|
+
<g class="deco-blink" ${_delay(i + 27, 4.0)}>
|
|
7782
|
+
<rect x="${x}" y="${y}" width="10" height="6" fill="var(--panel-3, #1A1A18)"/>
|
|
7783
|
+
<rect x="${x}" y="${y}" width="10" height="1" fill="var(--lime-dim, #2D5532)"/>
|
|
7784
|
+
<rect x="${x}" y="${y + 5}" width="10" height="1" fill="var(--lime-dim, #2D5532)"/>
|
|
7785
|
+
<rect x="${x}" y="${y}" width="1" height="6" fill="var(--lime-dim, #2D5532)"/>
|
|
7786
|
+
<rect x="${x + 9}" y="${y}" width="1" height="6" fill="var(--lime-dim, #2D5532)"/>
|
|
7787
|
+
<rect x="${x + 2}" y="${y + 6}" width="2" height="1" fill="var(--lime-dim, #2D5532)"/>
|
|
7788
|
+
</g>
|
|
7789
|
+
`).join("")}
|
|
7790
|
+
<!-- Idea sparkles · "+" glyphs in lime-dim · scale pulse -->
|
|
7791
|
+
${ideaSparks.map(([x, y], i) => `
|
|
7792
|
+
<g class="deco-spark" ${_delay(i + 33, 2.6)} fill="var(--lime-dim, #2D5532)">
|
|
7793
|
+
<rect x="${x}" y="${y + 2}" width="6" height="2"/>
|
|
7794
|
+
<rect x="${x + 2}" y="${y}" width="2" height="6"/>
|
|
7795
|
+
</g>
|
|
7796
|
+
`).join("")}
|
|
7797
|
+
</g>
|
|
7798
|
+
`;
|
|
7799
|
+
}
|
|
7800
|
+
return `
|
|
7801
|
+
<svg viewBox="0 0 800 280" preserveAspectRatio="xMidYMin slice"
|
|
7802
|
+
shape-rendering="crispEdges" aria-hidden="true">
|
|
7803
|
+
${scatter}
|
|
7804
|
+
${motif}
|
|
7805
|
+
</svg>
|
|
7806
|
+
`;
|
|
7807
|
+
},
|
|
7808
|
+
|
|
7180
7809
|
renderComposerHtml(state) {
|
|
7181
7810
|
const userName = (this.prefs?.name || "you").trim() || "you";
|
|
7182
7811
|
const lang = this.composerLanguage();
|
|
@@ -7266,6 +7895,7 @@
|
|
|
7266
7895
|
|
|
7267
7896
|
return `
|
|
7268
7897
|
<section class="cmp">
|
|
7898
|
+
<div class="cmp-bg-deco" aria-hidden="true">${this.composerBgDecoSvg("room")}</div>
|
|
7269
7899
|
<header class="cmp-hero">
|
|
7270
7900
|
<div class="cmp-greet">${this.escape(t.greet)}</div>
|
|
7271
7901
|
<h1 class="cmp-prompt">${this.escape(t.prompt)}</h1>
|
|
@@ -7531,6 +8161,20 @@
|
|
|
7531
8161
|
return MODEL_LABELS[modelV] || modelV;
|
|
7532
8162
|
},
|
|
7533
8163
|
|
|
8164
|
+
/** Friendly label for an agent's voice profile. Returns "" when
|
|
8165
|
+
* the agent has no voice set, so callers can omit a voice chip
|
|
8166
|
+
* entirely. Looks up the prefetched `voiceLabels` map (populated
|
|
8167
|
+
* by loadInitial) and falls back to the raw voiceId when the
|
|
8168
|
+
* prefetch missed (e.g. /api/voices failed, or the agent uses
|
|
8169
|
+
* a fresh cloned voice that wasn't in the snapshot). */
|
|
8170
|
+
voiceLabelFor(agent) {
|
|
8171
|
+
const v = agent && agent.voice;
|
|
8172
|
+
if (!v || !v.provider || !v.voiceId) return "";
|
|
8173
|
+
const key = `${v.provider}:${v.voiceId}`;
|
|
8174
|
+
const cached = this.voiceLabels && this.voiceLabels[key];
|
|
8175
|
+
return cached || v.voiceId;
|
|
8176
|
+
},
|
|
8177
|
+
|
|
7534
8178
|
setAgentComposerModel(modelV) {
|
|
7535
8179
|
if (!modelV) return;
|
|
7536
8180
|
// Accept any modelV the registry knows about (MODEL_LABELS) so
|
|
@@ -7864,6 +8508,7 @@
|
|
|
7864
8508
|
`).join("");
|
|
7865
8509
|
return `
|
|
7866
8510
|
<section class="cmp ag-cmp">
|
|
8511
|
+
<div class="cmp-bg-deco" aria-hidden="true">${this.composerBgDecoSvg("agent")}</div>
|
|
7867
8512
|
<header class="cmp-hero">
|
|
7868
8513
|
<div class="cmp-greet">${this.escape(t.greet)}</div>
|
|
7869
8514
|
<h1 class="cmp-prompt">${this.escape(t.prompt)}</h1>
|
|
@@ -11306,6 +11951,14 @@
|
|
|
11306
11951
|
const isUser = m.authorKind === "user";
|
|
11307
11952
|
const author = isUser ? null : this.agentsById[m.authorId];
|
|
11308
11953
|
const isChair = !isUser && author?.roleKind === "moderator";
|
|
11954
|
+
// Excused-from-room marker · the director is in this room's
|
|
11955
|
+
// historicalMembers with a non-null removedAt. Past messages
|
|
11956
|
+
// keep their name + role tag (so the chat transcript still
|
|
11957
|
+
// makes sense) and get a small "// excused" pill in the
|
|
11958
|
+
// header so the reader knows this seat is gone.
|
|
11959
|
+
const excusedMember = (!isUser && !isChair && m.authorId)
|
|
11960
|
+
? (this.currentHistoricalMembers || []).find((hm) => hm.id === m.authorId && hm.removedAt != null)
|
|
11961
|
+
: null;
|
|
11309
11962
|
const metaKind = m.meta && typeof m.meta.kind === "string" ? m.meta.kind : null;
|
|
11310
11963
|
|
|
11311
11964
|
// Convening · the chair's spoken introduction of the auto-
|
|
@@ -11706,6 +12359,7 @@
|
|
|
11706
12359
|
<span class="msg-name">${this.escape(name)}</span>
|
|
11707
12360
|
${modelLabel ? `<span class="msg-model" title="${this.escape(this._t("msg_model_title", { label: modelLabel }))}">${this.escape(modelLabel)}</span>` : ""}
|
|
11708
12361
|
<span class="msg-tag">${tag}</span>
|
|
12362
|
+
${excusedMember ? `<span class="msg-excused" title="Excused from this room by the chair">// excused</span>` : ""}
|
|
11709
12363
|
${skillsBadge}
|
|
11710
12364
|
${webSearchBadge}
|
|
11711
12365
|
${ctxBadge}
|
|
@@ -12609,11 +13263,14 @@
|
|
|
12609
13263
|
} else {
|
|
12610
13264
|
avatar = `<img class="rt-avatar" src="${this.escape(m.avatarPath || "")}" alt="" aria-hidden="true">`;
|
|
12611
13265
|
}
|
|
12612
|
-
// Name plate · adds a small "董事长" title beneath
|
|
12613
|
-
// name so the user seat reads as the room owner /
|
|
12614
|
-
//
|
|
13266
|
+
// Name plate · adds a small "Chairman / 董事长" title beneath
|
|
13267
|
+
// the user's name so the user seat reads as the room owner /
|
|
13268
|
+
// chairman. Pulled from i18n (`rt_user_title`) so the label
|
|
13269
|
+
// follows the active UI locale: defaults to "Chairman" in
|
|
13270
|
+
// English, "董事长" in Chinese. Directors and the chair
|
|
13271
|
+
// render the plain single-line name.
|
|
12615
13272
|
const name = isUser
|
|
12616
|
-
? `<div class="rt-name">${this.escape(m.name || "")}<div class="rt-name-title"
|
|
13273
|
+
? `<div class="rt-name">${this.escape(m.name || "")}<div class="rt-name-title">${this.escape(this._t("rt_user_title"))}</div></div>`
|
|
12617
13274
|
: `<div class="rt-name">${this.escape(m.name || "")}</div>`;
|
|
12618
13275
|
const bubbleState = isSpeaking ? speakerState : null;
|
|
12619
13276
|
// Bubble carries the speaker's NAME so the user always knows
|
|
@@ -14564,10 +15221,18 @@
|
|
|
14564
15221
|
e.preventDefault();
|
|
14565
15222
|
if (!app.currentRoomId) return;
|
|
14566
15223
|
if (window.boardroomVoiceReplay && typeof window.boardroomVoiceReplay.open === "function") {
|
|
15224
|
+
// Pass historicalMembers (includes excused directors) so
|
|
15225
|
+
// past messages from a director the chair has excused still
|
|
15226
|
+
// resolve to the speaker's name + voice profile. Falls back
|
|
15227
|
+
// to active members for legacy contexts where the historical
|
|
15228
|
+
// list isn't populated yet.
|
|
15229
|
+
const replayMembers = Array.isArray(app.currentHistoricalMembers) && app.currentHistoricalMembers.length > 0
|
|
15230
|
+
? app.currentHistoricalMembers.slice()
|
|
15231
|
+
: (Array.isArray(app.currentMembers) ? app.currentMembers.slice() : []);
|
|
14567
15232
|
window.boardroomVoiceReplay.open({
|
|
14568
15233
|
roomId: app.currentRoomId,
|
|
14569
15234
|
messages: Array.isArray(app.currentMessages) ? app.currentMessages.slice() : [],
|
|
14570
|
-
members:
|
|
15235
|
+
members: replayMembers,
|
|
14571
15236
|
chair: app.currentChair || null,
|
|
14572
15237
|
});
|
|
14573
15238
|
}
|
|
@@ -14584,6 +15249,45 @@
|
|
|
14584
15249
|
}
|
|
14585
15250
|
return;
|
|
14586
15251
|
}
|
|
15252
|
+
// Search view · starter chip click. Pre-fills the input with
|
|
15253
|
+
// the chip's keyword and triggers a search. Dispatching an
|
|
15254
|
+
// input event ensures the existing doc-level input listener
|
|
15255
|
+
// (which calls runSearch) fires too, so the debounced fetch
|
|
15256
|
+
// path stays canonical.
|
|
15257
|
+
const starter = e.target.closest("[data-search-starter]");
|
|
15258
|
+
if (starter) {
|
|
15259
|
+
e.preventDefault();
|
|
15260
|
+
const term = starter.getAttribute("data-search-starter") || "";
|
|
15261
|
+
const input = document.querySelector("[data-search-input]");
|
|
15262
|
+
if (input && term) {
|
|
15263
|
+
input.value = term;
|
|
15264
|
+
input.focus();
|
|
15265
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
15266
|
+
}
|
|
15267
|
+
return;
|
|
15268
|
+
}
|
|
15269
|
+
// Search view · sort chip ("Newest" / "Oldest"). Updates the
|
|
15270
|
+
// sort key + re-renders the cached result list client-side.
|
|
15271
|
+
// No re-fetch · the API doesn't expose a sort param and the
|
|
15272
|
+
// dataset is small (≤ 200 hits per query) so an in-memory
|
|
15273
|
+
// sort is the right call.
|
|
15274
|
+
const sortChip = e.target.closest("[data-search-sort-by]");
|
|
15275
|
+
if (sortChip) {
|
|
15276
|
+
e.preventDefault();
|
|
15277
|
+
const next = sortChip.getAttribute("data-search-sort-by") === "oldest" ? "oldest" : "newest";
|
|
15278
|
+
if (app._searchSort === next) return; // already active
|
|
15279
|
+
app._searchSort = next;
|
|
15280
|
+
app._refreshSortChips();
|
|
15281
|
+
// Re-render from the cached results · skip the no-cache
|
|
15282
|
+
// path (search not yet run) since the chip group is hidden
|
|
15283
|
+
// in is-initial.
|
|
15284
|
+
const cached = Array.isArray(app._searchLastResults) ? app._searchLastResults : null;
|
|
15285
|
+
const cachedQuery = app._searchLastQueryRendered || app._searchLastQuery || "";
|
|
15286
|
+
if (cached && cached.length > 0) {
|
|
15287
|
+
app.renderSearchResults(cached, cachedQuery);
|
|
15288
|
+
}
|
|
15289
|
+
return;
|
|
15290
|
+
}
|
|
14587
15291
|
// Search view · result row click. Anchor's href is
|
|
14588
15292
|
// `#/r/<id>?m=<mid>&q=<query>`, which the hashchange route
|
|
14589
15293
|
// handler picks up. We stash the pending message id + query
|