privateboard 0.1.13 → 0.1.15
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 +921 -25
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/public/app.js +1283 -27
- package/public/index.html +857 -49
package/public/app.js
CHANGED
|
@@ -94,6 +94,20 @@
|
|
|
94
94
|
* miss falls back to the raw voiceId so the row never blocks on
|
|
95
95
|
* the fetch. */
|
|
96
96
|
voiceLabels: {},
|
|
97
|
+
/** Interest-driven topic recommendations · the home composer's
|
|
98
|
+
* "找你可能感兴趣的话题" tray. Always exactly 6 items (or
|
|
99
|
+
* fewer if no batch has been generated yet) — every fresh
|
|
100
|
+
* generation wipes the previous batch server-side, so
|
|
101
|
+
* there's no pagination or history. `loaded` flips true
|
|
102
|
+
* after the first `/api/topic-recs` fetch so first-paint
|
|
103
|
+
* can show a sensible empty state. `job` is the live
|
|
104
|
+
* generation job (id + phase + pct + detail) or null when
|
|
105
|
+
* idle. */
|
|
106
|
+
topicRecs: {
|
|
107
|
+
items: [],
|
|
108
|
+
loaded: false,
|
|
109
|
+
job: null, // { id, phase, label, pct, detail, eventSource }
|
|
110
|
+
},
|
|
97
111
|
rooms: [],
|
|
98
112
|
currentRoomId: null,
|
|
99
113
|
currentRoom: null,
|
|
@@ -144,6 +158,21 @@
|
|
|
144
158
|
* stays after the bubble dismisses; only this object resets.
|
|
145
159
|
* `dismissed: true` means no bubble is showing right now. */
|
|
146
160
|
userBubble: { text: "", deadline: 0, intervalId: null, dismissed: true },
|
|
161
|
+
/** Ephemeral question bubble pinned to the CHAIR seat when the
|
|
162
|
+
* chair drops a clarifying question in voice mode. Mirrors the
|
|
163
|
+
* userBubble shape exactly so the render / tick / dismiss
|
|
164
|
+
* surfaces stay parallel. 10s border countdown. */
|
|
165
|
+
chairBubble: { text: "", deadline: 0, intervalId: null, dismissed: true },
|
|
166
|
+
/** Set of chair messageIds whose voice synthesis hasn't started yet.
|
|
167
|
+
* Bridges the gap between message-appended (the chair's round-prompt
|
|
168
|
+
* / round-end placeholder lands instantly) and the first voice-chunk
|
|
169
|
+
* arriving from the server's TTS pipeline (~0.5-2s later). Without
|
|
170
|
+
* this, isChairBusy briefly returns false in that window and the
|
|
171
|
+
* vote popover flashes onto the chair seat before the chair has
|
|
172
|
+
* even started speaking — then hides again as audio kicks in, then
|
|
173
|
+
* re-appears after audio ends. Cleared when the first voice-chunk
|
|
174
|
+
* arrives (or after a 12s safety timeout if TTS never delivers). */
|
|
175
|
+
_chairVoiceAwaiting: null,
|
|
147
176
|
currentBrief: null,
|
|
148
177
|
/** All briefs filed for the current room · newest first. */
|
|
149
178
|
currentBriefs: [],
|
|
@@ -768,6 +797,14 @@
|
|
|
768
797
|
clearInterval(this.userBubble.intervalId);
|
|
769
798
|
}
|
|
770
799
|
this.userBubble = { text: "", deadline: 0, intervalId: null, dismissed: true };
|
|
800
|
+
if (this.chairBubble && this.chairBubble.intervalId) {
|
|
801
|
+
clearInterval(this.chairBubble.intervalId);
|
|
802
|
+
}
|
|
803
|
+
this.chairBubble = { text: "", deadline: 0, intervalId: null, dismissed: true };
|
|
804
|
+
// Drop any leftover voice-await IDs from a prior room · timeouts
|
|
805
|
+
// that fire later will harmlessly no-op (their messageId isn't
|
|
806
|
+
// in the set anymore).
|
|
807
|
+
if (this._chairVoiceAwaiting) this._chairVoiceAwaiting.clear();
|
|
771
808
|
// Chairman's notes for this room · fetched in parallel-ish
|
|
772
809
|
// with the room body so the in-room highlight overlay can
|
|
773
810
|
// wrap saved spans on first paint. Stored as a Map keyed by
|
|
@@ -943,6 +980,14 @@
|
|
|
943
980
|
clearInterval(this.userBubble.intervalId);
|
|
944
981
|
}
|
|
945
982
|
this.userBubble = { text: "", deadline: 0, intervalId: null, dismissed: true };
|
|
983
|
+
if (this.chairBubble && this.chairBubble.intervalId) {
|
|
984
|
+
clearInterval(this.chairBubble.intervalId);
|
|
985
|
+
}
|
|
986
|
+
this.chairBubble = { text: "", deadline: 0, intervalId: null, dismissed: true };
|
|
987
|
+
// Drop any leftover voice-await IDs from a prior room · timeouts
|
|
988
|
+
// that fire later will harmlessly no-op (their messageId isn't
|
|
989
|
+
// in the set anymore).
|
|
990
|
+
if (this._chairVoiceAwaiting) this._chairVoiceAwaiting.clear();
|
|
946
991
|
this.currentBrief = null;
|
|
947
992
|
this.currentBriefs = [];
|
|
948
993
|
this.currentParentRef = null;
|
|
@@ -1027,6 +1072,30 @@
|
|
|
1027
1072
|
if (data.authorKind === "agent") {
|
|
1028
1073
|
this.hideChairPending();
|
|
1029
1074
|
}
|
|
1075
|
+
// Chair vote-trigger message · register it as awaiting voice
|
|
1076
|
+
// so the vote popover stays suppressed through the gap
|
|
1077
|
+
// between message-appended and the first voice-chunk. Scoped
|
|
1078
|
+
// to round-prompt + round-end (the two kinds that surface the
|
|
1079
|
+
// chair-seat popover) so unrelated chair pings don't gate
|
|
1080
|
+
// anything. Voice-mode only · text mode has no such gap.
|
|
1081
|
+
if (
|
|
1082
|
+
data.authorKind === "agent"
|
|
1083
|
+
&& this.currentChair && data.authorId === this.currentChair.id
|
|
1084
|
+
&& data.meta && (data.meta.kind === "round-prompt" || data.meta.kind === "round-end")
|
|
1085
|
+
&& this.currentRoom && this.currentRoom.deliveryMode === "voice"
|
|
1086
|
+
) {
|
|
1087
|
+
this._chairVoiceAwaiting = this._chairVoiceAwaiting || new Set();
|
|
1088
|
+
this._chairVoiceAwaiting.add(data.messageId);
|
|
1089
|
+
const mid = data.messageId;
|
|
1090
|
+
// Safety net · 12s ceiling in case TTS never delivers
|
|
1091
|
+
// (network drop, server failure). Triggers a repaint so the
|
|
1092
|
+
// panel can finally surface without the user being stuck.
|
|
1093
|
+
setTimeout(() => {
|
|
1094
|
+
if (this._chairVoiceAwaiting && this._chairVoiceAwaiting.delete(mid)) {
|
|
1095
|
+
this.renderRoundTable();
|
|
1096
|
+
}
|
|
1097
|
+
}, 12000);
|
|
1098
|
+
}
|
|
1030
1099
|
// Convening card · clear the moment any chair message lands
|
|
1031
1100
|
// (convening speech, clarify, anything). The card has done
|
|
1032
1101
|
// its job; the chair is taking over from here. We re-render
|
|
@@ -1067,6 +1136,21 @@
|
|
|
1067
1136
|
this.userSeatVisible = true;
|
|
1068
1137
|
this.showUserBubble(data.body || "");
|
|
1069
1138
|
}
|
|
1139
|
+
// Chair clarify bubble · drop it the moment ANY new message
|
|
1140
|
+
// arrives that isn't another chair clarify (a user reply, a
|
|
1141
|
+
// director turn, or the chair moving past the clarify
|
|
1142
|
+
// phase). Without this the 10s timer alone leaves the
|
|
1143
|
+
// question hovering over the chair seat while the user's
|
|
1144
|
+
// own reply bubble or the next speaker's turn already
|
|
1145
|
+
// owns the stage — visually confusing.
|
|
1146
|
+
if (this.chairBubble && !this.chairBubble.dismissed) {
|
|
1147
|
+
const isAnotherClarify =
|
|
1148
|
+
data.authorKind === "agent" &&
|
|
1149
|
+
this.currentChair &&
|
|
1150
|
+
data.authorId === this.currentChair.id &&
|
|
1151
|
+
(data.meta || {}).kind === "clarify";
|
|
1152
|
+
if (!isAnotherClarify) this.dismissChairBubble();
|
|
1153
|
+
}
|
|
1070
1154
|
// Round-table toasts · gamified surface for chair-emitted
|
|
1071
1155
|
// template messages (round-open marker, etc.) that the
|
|
1072
1156
|
// chat normally renders as system cards. Only toast for
|
|
@@ -1160,6 +1244,21 @@
|
|
|
1160
1244
|
msg.meta.streaming = false;
|
|
1161
1245
|
msg.meta.speakerStatus = "final";
|
|
1162
1246
|
}
|
|
1247
|
+
// Chair clarify · in voice mode, the chair's clarifying
|
|
1248
|
+
// question just finished streaming. Pin the question text to
|
|
1249
|
+
// the chair seat as a 10s countdown bubble (border-progress
|
|
1250
|
+
// ring, same mechanic as the user bubble). Voice-mode only ·
|
|
1251
|
+
// text mode users read the question inline in the scroll, so
|
|
1252
|
+
// a duplicate bubble would just clutter the stage.
|
|
1253
|
+
if (
|
|
1254
|
+
msg && msg.authorKind === "agent"
|
|
1255
|
+
&& this.currentChair && msg.authorId === this.currentChair.id
|
|
1256
|
+
&& msg.meta && msg.meta.kind === "clarify"
|
|
1257
|
+
&& this.currentRoom && this.currentRoom.deliveryMode === "voice"
|
|
1258
|
+
&& this.currentRoom.status !== "adjourned"
|
|
1259
|
+
) {
|
|
1260
|
+
this.showChairBubble(msg.body);
|
|
1261
|
+
}
|
|
1163
1262
|
// Round-table stage · the speaker just finished, so the
|
|
1164
1263
|
// bubble should drop. Repaint the seats so the lime ring
|
|
1165
1264
|
// / bubble migrate to whoever's next (director queue head)
|
|
@@ -1208,6 +1307,13 @@
|
|
|
1208
1307
|
// meta.streaming. Skipping when a queue already exists keeps
|
|
1209
1308
|
// the SSE hot path cheap (we don't repaint per chunk).
|
|
1210
1309
|
const fresh = !this.voiceQueues[data.messageId];
|
|
1310
|
+
// Voice has actually arrived for this messageId · clear it
|
|
1311
|
+
// from the awaiting-voice set so isChairBusy hands off to the
|
|
1312
|
+
// voiceQueues check (which keeps the panel suppressed until
|
|
1313
|
+
// _fireVoiceDone removes the queue at audio end).
|
|
1314
|
+
if (fresh && this._chairVoiceAwaiting) {
|
|
1315
|
+
this._chairVoiceAwaiting.delete(data.messageId);
|
|
1316
|
+
}
|
|
1211
1317
|
// Chair gavel SFX · fire ONCE when fresh chair voice starts
|
|
1212
1318
|
// streaming, so the user hears the courtroom "knock-knock"
|
|
1213
1319
|
// calling for attention before the chair speaks. Detection:
|
|
@@ -1959,7 +2065,7 @@
|
|
|
1959
2065
|
},
|
|
1960
2066
|
|
|
1961
2067
|
// ── Actions ───────────────────────────────────────────────
|
|
1962
|
-
async createRoom({ subject, agentIds, mode, intensity, briefStyle, autoPick, deliveryMode }) {
|
|
2068
|
+
async createRoom({ subject, agentIds, mode, intensity, briefStyle, autoPick, deliveryMode, seedContext }) {
|
|
1963
2069
|
const r = await fetch("/api/rooms", {
|
|
1964
2070
|
method: "POST",
|
|
1965
2071
|
headers: { "content-type": "application/json" },
|
|
@@ -1971,6 +2077,11 @@
|
|
|
1971
2077
|
briefStyle: briefStyle || "auto",
|
|
1972
2078
|
deliveryMode: deliveryMode === "voice" ? "voice" : "text",
|
|
1973
2079
|
...(autoPick ? { autoPick: true } : {}),
|
|
2080
|
+
// seedContext · attached when the user opened this room
|
|
2081
|
+
// from a topic-rec card. Backend writes it to the
|
|
2082
|
+
// opening message's meta so the chair grounds clarify
|
|
2083
|
+
// in the actual source snippets.
|
|
2084
|
+
...(seedContext ? { seedContext } : {}),
|
|
1974
2085
|
}),
|
|
1975
2086
|
});
|
|
1976
2087
|
if (!r.ok) {
|
|
@@ -4235,6 +4346,23 @@
|
|
|
4235
4346
|
// Pre-flight · the next round will fire a fresh director queue,
|
|
4236
4347
|
// each turn requiring a model key.
|
|
4237
4348
|
if (!(await this.requireModelKey())) return;
|
|
4349
|
+
// Paused-room handling · the chair's vote popover stays mounted
|
|
4350
|
+
// through pause (the user can park the room mid-vote to think),
|
|
4351
|
+
// so its Continue / Switch / Keep buttons must still work. The
|
|
4352
|
+
// /continue endpoint requires `status === "live"`, so resume
|
|
4353
|
+
// first when the room is paused — without this, the POST 409s
|
|
4354
|
+
// and benignRace silently swallows the error, leaving the user
|
|
4355
|
+
// clicking a dead button. The shift-accept flow funnels through
|
|
4356
|
+
// here too via acceptModeShiftAndContinue, so this single hop
|
|
4357
|
+
// covers both buttons.
|
|
4358
|
+
if (this.currentRoom && this.currentRoom.status === "paused") {
|
|
4359
|
+
try {
|
|
4360
|
+
await this.resumeRoom();
|
|
4361
|
+
} catch (err) {
|
|
4362
|
+
alert("Resume failed: " + (err && err.message ? err.message : err));
|
|
4363
|
+
return;
|
|
4364
|
+
}
|
|
4365
|
+
}
|
|
4238
4366
|
const r = await fetch(
|
|
4239
4367
|
"/api/rooms/" + encodeURIComponent(this.currentRoomId) + "/continue",
|
|
4240
4368
|
{ method: "POST" },
|
|
@@ -5517,10 +5645,19 @@
|
|
|
5517
5645
|
const raw = localStorage.getItem("boardroom.composer");
|
|
5518
5646
|
if (raw) saved = JSON.parse(raw);
|
|
5519
5647
|
} catch { /* ignore */ }
|
|
5648
|
+
// seedContext · pre-fetched web snippets the user attached
|
|
5649
|
+
// by clicking a topic-rec card on the home composer. Round-
|
|
5650
|
+
// tripped through localStorage so a page refresh between
|
|
5651
|
+
// pick and convene doesn't lose the attached source
|
|
5652
|
+
// material. Shape: { topicRecId?: string, snippets?: [] }.
|
|
5653
|
+
const savedSeed = saved && typeof saved.seedContext === "object" && saved.seedContext
|
|
5654
|
+
? saved.seedContext
|
|
5655
|
+
: null;
|
|
5520
5656
|
this.composerState = {
|
|
5521
5657
|
...this.DEFAULT_COMPOSER,
|
|
5522
5658
|
...(saved || {}),
|
|
5523
5659
|
subject: (saved && typeof saved.subject === "string") ? saved.subject : "",
|
|
5660
|
+
seedContext: savedSeed,
|
|
5524
5661
|
};
|
|
5525
5662
|
return this.composerState;
|
|
5526
5663
|
},
|
|
@@ -5528,10 +5665,10 @@
|
|
|
5528
5665
|
saveComposerState() {
|
|
5529
5666
|
if (!this.composerState) return;
|
|
5530
5667
|
try {
|
|
5531
|
-
const { directorIds, mode, intensity, deliveryMode, autoPickDirectors, subject } = this.composerState;
|
|
5668
|
+
const { directorIds, mode, intensity, deliveryMode, autoPickDirectors, subject, seedContext } = this.composerState;
|
|
5532
5669
|
localStorage.setItem(
|
|
5533
5670
|
"boardroom.composer",
|
|
5534
|
-
JSON.stringify({ directorIds, mode, intensity, deliveryMode, autoPickDirectors, subject }),
|
|
5671
|
+
JSON.stringify({ directorIds, mode, intensity, deliveryMode, autoPickDirectors, subject, seedContext: seedContext || null }),
|
|
5535
5672
|
);
|
|
5536
5673
|
} catch { /* ignore */ }
|
|
5537
5674
|
},
|
|
@@ -5574,6 +5711,15 @@
|
|
|
5574
5711
|
// overlay — typing in this view + Enter creates the room.
|
|
5575
5712
|
const head = document.querySelector("[data-room-head]");
|
|
5576
5713
|
if (head) head.innerHTML = ""; // CSS hides via html.no-room
|
|
5714
|
+
// One-shot lazy fetch · the composer's "or try a starter"
|
|
5715
|
+
// tray renders the latest topic recommendations when any
|
|
5716
|
+
// exist (replacing the legacy hardcoded starters). Skipping
|
|
5717
|
+
// when already loaded keeps re-renders cheap; the trigger
|
|
5718
|
+
// button's SSE path explicitly refreshes after a successful
|
|
5719
|
+
// generation so the list lands without polling.
|
|
5720
|
+
if (!this.topicRecs.loaded) {
|
|
5721
|
+
void this.refreshTopicRecs();
|
|
5722
|
+
}
|
|
5577
5723
|
|
|
5578
5724
|
// Strip stale follow-up fragments inserted by renderFollowUp-
|
|
5579
5725
|
// Fragments() when a follow-up room was previously open. These
|
|
@@ -5664,12 +5810,19 @@
|
|
|
5664
5810
|
},
|
|
5665
5811
|
|
|
5666
5812
|
/** Toggle `.chat--composer-overflow` on the chat scroller based on
|
|
5667
|
-
* whether the composer's natural height exceeds
|
|
5668
|
-
* area. In overflow mode the hero
|
|
5669
|
-
*
|
|
5670
|
-
* without overflow, the parent's
|
|
5671
|
-
* the whole composer block
|
|
5672
|
-
* and on window resize.
|
|
5813
|
+
* whether the composer's natural height exceeds 70% of the
|
|
5814
|
+
* visible chat area. In overflow mode the hero pins to a fixed
|
|
5815
|
+
* 120px top offset (`.cmp` `padding-top: 120px`) and the
|
|
5816
|
+
* starters tray flows below; without overflow, the parent's
|
|
5817
|
+
* `align-content: center` keeps the whole composer block
|
|
5818
|
+
* centred. Called after composer render and on window resize.
|
|
5819
|
+
*
|
|
5820
|
+
* The 0.7 threshold (was "strictly > viewport") catches the
|
|
5821
|
+
* in-between case where the input + ~6 topic-rec cards fit
|
|
5822
|
+
* vertically but only just — centred layout would visually
|
|
5823
|
+
* cram the hero against the top edge. Flipping to overflow
|
|
5824
|
+
* mode at 70% gives the hero comfortable breathing room
|
|
5825
|
+
* before any actual scroll is needed. */
|
|
5673
5826
|
updateComposerOverflow() {
|
|
5674
5827
|
const chat = document.querySelector(".chat.chat--composer");
|
|
5675
5828
|
if (!chat) return;
|
|
@@ -5677,7 +5830,7 @@
|
|
|
5677
5830
|
if (!cmp) return;
|
|
5678
5831
|
requestAnimationFrame(() => {
|
|
5679
5832
|
chat.classList.remove("chat--composer-overflow");
|
|
5680
|
-
const overflows = cmp.scrollHeight > chat.clientHeight
|
|
5833
|
+
const overflows = cmp.scrollHeight > chat.clientHeight * 0.7;
|
|
5681
5834
|
chat.classList.toggle("chat--composer-overflow", overflows);
|
|
5682
5835
|
});
|
|
5683
5836
|
if (!this._composerResizeAttached) {
|
|
@@ -6951,12 +7104,11 @@
|
|
|
6951
7104
|
const author = n.authorName || this._t("notes_director_fallback");
|
|
6952
7105
|
// The jump link uses the room's hash route + the note id as a
|
|
6953
7106
|
// fragment-style query so openRoom can scroll to + flash the
|
|
6954
|
-
// matching span. The
|
|
6955
|
-
// anchor (inside the .notes-item) so
|
|
7107
|
+
// matching span. The action buttons sit as siblings of the
|
|
7108
|
+
// anchor (inside the .notes-item) so their clicks never bubble
|
|
6956
7109
|
// through the navigation link — same pattern as session-row
|
|
6957
7110
|
// delete in the rooms sidebar.
|
|
6958
7111
|
const href = `#/r/${this.escape(n.roomId)}?note=${this.escape(n.id)}`;
|
|
6959
|
-
const deleteTitle = "Delete this note";
|
|
6960
7112
|
return `
|
|
6961
7113
|
<li class="notes-item" data-note-id="${this.escape(n.id)}">
|
|
6962
7114
|
<a class="notes-item-link" href="${href}" data-note-jump="${this.escape(n.id)}" data-note-room="${this.escape(n.roomId)}">
|
|
@@ -6973,18 +7125,691 @@
|
|
|
6973
7125
|
n.contextAfter ? `<span class="note-context note-context-after">${this.escape(n.contextAfter)}</span>` : ""
|
|
6974
7126
|
}</p>
|
|
6975
7127
|
</a>
|
|
6976
|
-
<
|
|
7128
|
+
<div class="notes-item-actions">
|
|
7129
|
+
<button type="button" class="notes-item-action notes-item-share" data-note-share aria-label="Share this note">
|
|
7130
|
+
<span class="notes-action-glyph" aria-hidden="true">↗</span>
|
|
7131
|
+
<span class="notes-action-label">Share</span>
|
|
7132
|
+
</button>
|
|
7133
|
+
<button type="button" class="notes-item-action notes-item-unfav" data-note-delete aria-label="Remove this note from your saved list">
|
|
7134
|
+
<span class="notes-action-glyph" aria-hidden="true">✕</span>
|
|
7135
|
+
<span class="notes-action-label">Unfavorite</span>
|
|
7136
|
+
</button>
|
|
7137
|
+
</div>
|
|
6977
7138
|
</li>
|
|
6978
7139
|
`;
|
|
6979
7140
|
},
|
|
6980
7141
|
|
|
7142
|
+
/** Share-card overlay · mounts a modal with multiple card
|
|
7143
|
+
* templates rendering the selected note as a shareable image.
|
|
7144
|
+
* Templates live in CSS (data-share-template attribute swaps
|
|
7145
|
+
* the visual register without re-rendering markup). PNG export
|
|
7146
|
+
* reuses the html-to-image CDN loader pattern from
|
|
7147
|
+
* `public/magazine.html` · lazy-loaded on first download so
|
|
7148
|
+
* the All Notes page doesn't pay the network cost upfront.
|
|
7149
|
+
*
|
|
7150
|
+
* Each template is fixed at 540×675 (4:5 portrait) — a sweet
|
|
7151
|
+
* spot for IG / Weibo / WeChat moments / Twitter portrait. PNG
|
|
7152
|
+
* export uses pixelRatio: 2 so the saved file is 1080×1350. */
|
|
7153
|
+
SHARE_CARD_TEMPLATES: ["boardroom", "editorial", "terminal", "magazine"],
|
|
7154
|
+
DEFAULT_SHARE_CARD_TEMPLATE: "boardroom",
|
|
7155
|
+
|
|
7156
|
+
/** Open the share-card overlay for a given note id. Resolves the
|
|
7157
|
+
* note from the in-memory cache (`_notesCache`) so no network
|
|
7158
|
+
* round-trip is needed; the cache is populated by the All Notes
|
|
7159
|
+
* fetch and stays fresh through SSE note:created events. */
|
|
7160
|
+
openShareCard(noteId) {
|
|
7161
|
+
const note = (this._notesCache || []).find((n) => n && n.id === noteId);
|
|
7162
|
+
if (!note) {
|
|
7163
|
+
alert("Couldn't find that note — try reloading the page.");
|
|
7164
|
+
return;
|
|
7165
|
+
}
|
|
7166
|
+
// Tear down any prior overlay first · prevents stacking when
|
|
7167
|
+
// the user click-spams Share across different notes.
|
|
7168
|
+
this.closeShareCard();
|
|
7169
|
+
this._shareCardNote = note;
|
|
7170
|
+
this._shareCardTemplate = this.DEFAULT_SHARE_CARD_TEMPLATE;
|
|
7171
|
+
|
|
7172
|
+
// Share-card overlay reuses the room-settings overlay chrome
|
|
7173
|
+
// (`.room-settings-overlay` + `.room-settings-modal` + lime
|
|
7174
|
+
// corner brackets + `.rs-classification` strip + `.rs-head`
|
|
7175
|
+
// header + `.rs-body` scroll area + `.rs-foot` footer) so the
|
|
7176
|
+
// app's overlays share a single visual register. Inner content
|
|
7177
|
+
// (template chips + preview + download button) is local to
|
|
7178
|
+
// this feature.
|
|
7179
|
+
const roomNum = note.roomNumber != null
|
|
7180
|
+
? `#${String(note.roomNumber).padStart(3, "0")}` : "—";
|
|
7181
|
+
const author = note.authorName || "Director";
|
|
7182
|
+
const overlay = document.createElement("div");
|
|
7183
|
+
overlay.className = "room-settings-overlay open";
|
|
7184
|
+
overlay.id = "share-card-overlay";
|
|
7185
|
+
overlay.setAttribute("role", "dialog");
|
|
7186
|
+
overlay.setAttribute("aria-modal", "true");
|
|
7187
|
+
overlay.setAttribute("aria-label", "Share this note");
|
|
7188
|
+
overlay.innerHTML = `
|
|
7189
|
+
<div class="room-settings-modal" role="document">
|
|
7190
|
+
<div class="rs-classification">
|
|
7191
|
+
<span><span class="dot">●</span> share · note</span>
|
|
7192
|
+
<span class="right">// privateboard.ai</span>
|
|
7193
|
+
</div>
|
|
7194
|
+
<header class="rs-head">
|
|
7195
|
+
<div class="rs-head-text">
|
|
7196
|
+
<div class="meta">// from room ${this.escape(roomNum)} · ${this.escape(author)}</div>
|
|
7197
|
+
<div class="rs-title-wrap">
|
|
7198
|
+
<div class="title rs-title">Share this note as a card</div>
|
|
7199
|
+
</div>
|
|
7200
|
+
</div>
|
|
7201
|
+
<button type="button" class="close-btn" data-share-card-close aria-label="Close">✕</button>
|
|
7202
|
+
</header>
|
|
7203
|
+
<div class="rs-body">
|
|
7204
|
+
<div class="share-card-templates" role="tablist" aria-label="Card template">
|
|
7205
|
+
${this.SHARE_CARD_TEMPLATES.map((t) => `
|
|
7206
|
+
<button type="button"
|
|
7207
|
+
class="share-card-template-chip${t === this._shareCardTemplate ? " active" : ""}"
|
|
7208
|
+
data-share-card-template="${t}"
|
|
7209
|
+
role="tab"
|
|
7210
|
+
aria-selected="${t === this._shareCardTemplate ? "true" : "false"}"
|
|
7211
|
+
>${this.escape(t)}</button>
|
|
7212
|
+
`).join("")}
|
|
7213
|
+
</div>
|
|
7214
|
+
<div class="share-card-preview" data-share-card-preview>
|
|
7215
|
+
<div class="share-card-preview-inner" data-share-card-preview-inner>
|
|
7216
|
+
${this.renderShareCardHtml(note, this._shareCardTemplate)}
|
|
7217
|
+
</div>
|
|
7218
|
+
</div>
|
|
7219
|
+
</div>
|
|
7220
|
+
<footer class="rs-foot">
|
|
7221
|
+
<span class="share-card-hint">// 1080 × 1350 png</span>
|
|
7222
|
+
<button type="button" class="rs-action dirty" data-share-card-download>
|
|
7223
|
+
↓ Download PNG
|
|
7224
|
+
</button>
|
|
7225
|
+
</footer>
|
|
7226
|
+
</div>
|
|
7227
|
+
`;
|
|
7228
|
+
document.body.appendChild(overlay);
|
|
7229
|
+
|
|
7230
|
+
// Fit the 540×800 preview into whatever container the viewport
|
|
7231
|
+
// gives us. CSS caps both width (max-width: 540) AND height
|
|
7232
|
+
// (max-height: calc(100vh - 260px)) so on short viewports the
|
|
7233
|
+
// preview shrinks proportionally — scaling must read whichever
|
|
7234
|
+
// dimension landed smaller so the inner 540×800 native render
|
|
7235
|
+
// fits cleanly without any scrollbars on the modal body.
|
|
7236
|
+
const fitPreview = () => {
|
|
7237
|
+
const preview = overlay.querySelector("[data-share-card-preview]");
|
|
7238
|
+
const inner = overlay.querySelector("[data-share-card-preview-inner]");
|
|
7239
|
+
if (!preview || !inner) return;
|
|
7240
|
+
const rect = preview.getBoundingClientRect();
|
|
7241
|
+
const w = rect.width || 540;
|
|
7242
|
+
const h = rect.height || 800;
|
|
7243
|
+
const scale = Math.min(1, w / 540, h / 800);
|
|
7244
|
+
inner.style.setProperty("--share-card-scale", scale.toFixed(4));
|
|
7245
|
+
};
|
|
7246
|
+
fitPreview();
|
|
7247
|
+
this._shareCardResize = fitPreview;
|
|
7248
|
+
window.addEventListener("resize", this._shareCardResize);
|
|
7249
|
+
|
|
7250
|
+
// Esc closes · same shortcut family as other overlays.
|
|
7251
|
+
this._shareCardEsc = (ev) => {
|
|
7252
|
+
if (ev.key === "Escape") {
|
|
7253
|
+
ev.preventDefault();
|
|
7254
|
+
this.closeShareCard();
|
|
7255
|
+
}
|
|
7256
|
+
};
|
|
7257
|
+
document.addEventListener("keydown", this._shareCardEsc, true);
|
|
7258
|
+
|
|
7259
|
+
// Pre-warm html-to-image so the first Download click doesn't
|
|
7260
|
+
// pay the ~50KB CDN fetch latency. Best-effort; if the network
|
|
7261
|
+
// is slow the download path awaits its own ensure() anyway.
|
|
7262
|
+
this.ensureShareCardHtmlToImage().catch(() => { /* swallow */ });
|
|
7263
|
+
},
|
|
7264
|
+
|
|
7265
|
+
closeShareCard() {
|
|
7266
|
+
const el = document.getElementById("share-card-overlay");
|
|
7267
|
+
if (el) el.remove();
|
|
7268
|
+
if (this._shareCardEsc) {
|
|
7269
|
+
document.removeEventListener("keydown", this._shareCardEsc, true);
|
|
7270
|
+
this._shareCardEsc = null;
|
|
7271
|
+
}
|
|
7272
|
+
if (this._shareCardResize) {
|
|
7273
|
+
window.removeEventListener("resize", this._shareCardResize);
|
|
7274
|
+
this._shareCardResize = null;
|
|
7275
|
+
}
|
|
7276
|
+
this._shareCardNote = null;
|
|
7277
|
+
this._shareCardTemplate = null;
|
|
7278
|
+
},
|
|
7279
|
+
|
|
7280
|
+
/** Swap the active template · re-renders only the preview's
|
|
7281
|
+
* inner block (keeps the chip row + modal chrome stable). The
|
|
7282
|
+
* data-share-template attribute on `.share-card` is what every
|
|
7283
|
+
* template's CSS keys off, so the visual change is purely a
|
|
7284
|
+
* class swap — markup is identical across templates. */
|
|
7285
|
+
setShareCardTemplate(key) {
|
|
7286
|
+
if (!this.SHARE_CARD_TEMPLATES.includes(key)) return;
|
|
7287
|
+
this._shareCardTemplate = key;
|
|
7288
|
+
const overlay = document.getElementById("share-card-overlay");
|
|
7289
|
+
if (!overlay) return;
|
|
7290
|
+
overlay.querySelectorAll("[data-share-card-template]").forEach((btn) => {
|
|
7291
|
+
const active = btn.getAttribute("data-share-card-template") === key;
|
|
7292
|
+
btn.classList.toggle("active", active);
|
|
7293
|
+
btn.setAttribute("aria-selected", active ? "true" : "false");
|
|
7294
|
+
});
|
|
7295
|
+
const inner = overlay.querySelector("[data-share-card-preview-inner]");
|
|
7296
|
+
if (inner && this._shareCardNote) {
|
|
7297
|
+
inner.innerHTML = this.renderShareCardHtml(this._shareCardNote, key);
|
|
7298
|
+
}
|
|
7299
|
+
},
|
|
7300
|
+
|
|
7301
|
+
/** Length-based font-size step-down for share-card quotes.
|
|
7302
|
+
* Quotes never get truncated — instead the font shrinks so the
|
|
7303
|
+
* full passage fits. Returns the font-size in px for a given
|
|
7304
|
+
* template at the supplied character count. Tiers were tuned
|
|
7305
|
+
* empirically against each template's content area: the
|
|
7306
|
+
* boardroom bubble is the smallest box (≈424 × 200), magazine
|
|
7307
|
+
* and editorial have the most real estate.
|
|
7308
|
+
*
|
|
7309
|
+
* Length tiers are deliberately coarse — 5-6 buckets keep the
|
|
7310
|
+
* output predictable and avoid the "text snaps a pixel smaller
|
|
7311
|
+
* every keystroke" feel a continuous formula would produce. */
|
|
7312
|
+
shareCardQuoteSize(template, len) {
|
|
7313
|
+
const TIERS = {
|
|
7314
|
+
boardroom: [
|
|
7315
|
+
{ max: 40, size: 24 },
|
|
7316
|
+
{ max: 70, size: 21 },
|
|
7317
|
+
{ max: 100, size: 19 },
|
|
7318
|
+
{ max: 140, size: 16 },
|
|
7319
|
+
{ max: 170, size: 14 },
|
|
7320
|
+
{ max: 200, size: 13 },
|
|
7321
|
+
],
|
|
7322
|
+
editorial: [
|
|
7323
|
+
{ max: 40, size: 32 },
|
|
7324
|
+
{ max: 70, size: 28 },
|
|
7325
|
+
{ max: 100, size: 25 },
|
|
7326
|
+
{ max: 140, size: 21 },
|
|
7327
|
+
{ max: 170, size: 18 },
|
|
7328
|
+
{ max: 200, size: 16 },
|
|
7329
|
+
],
|
|
7330
|
+
terminal: [
|
|
7331
|
+
{ max: 40, size: 22 },
|
|
7332
|
+
{ max: 70, size: 19 },
|
|
7333
|
+
{ max: 100, size: 17 },
|
|
7334
|
+
{ max: 140, size: 15 },
|
|
7335
|
+
{ max: 170, size: 13 },
|
|
7336
|
+
{ max: 200, size: 12 },
|
|
7337
|
+
],
|
|
7338
|
+
magazine: [
|
|
7339
|
+
{ max: 40, size: 34 },
|
|
7340
|
+
{ max: 70, size: 30 },
|
|
7341
|
+
{ max: 100, size: 26 },
|
|
7342
|
+
{ max: 140, size: 22 },
|
|
7343
|
+
{ max: 170, size: 19 },
|
|
7344
|
+
{ max: 200, size: 17 },
|
|
7345
|
+
],
|
|
7346
|
+
};
|
|
7347
|
+
const fallback = { max: 200, size: 14 };
|
|
7348
|
+
const tiers = TIERS[template] || TIERS.boardroom;
|
|
7349
|
+
for (const t of tiers) {
|
|
7350
|
+
if (len <= t.max) return t.size;
|
|
7351
|
+
}
|
|
7352
|
+
// > 200 chars · take the smallest tier and shave 2px so the
|
|
7353
|
+
// text still has a chance to fit. Caller can also pre-trim
|
|
7354
|
+
// to 200 if they want a hard cap.
|
|
7355
|
+
return Math.max(10, tiers[tiers.length - 1].size - 2);
|
|
7356
|
+
},
|
|
7357
|
+
|
|
7358
|
+
/** Wrap CJK runs in `<span class="cjk">…</span>` so per-script
|
|
7359
|
+
* font + weight rules apply: CJK switches to PingFang at bold
|
|
7360
|
+
* weight (no italic-skew), Latin keeps the surrounding serif
|
|
7361
|
+
* italic. Always called AFTER `escape()` so the regex only
|
|
7362
|
+
* matches CJK glyphs and never sees raw `<` / `>` / `&`. */
|
|
7363
|
+
wrapCjk(text) {
|
|
7364
|
+
if (!text) return "";
|
|
7365
|
+
return String(text).replace(
|
|
7366
|
+
/([ -〿-ゟ゠-ヿ㐀-䶿一-鿿豈--]+)/g,
|
|
7367
|
+
'<span class="cjk">$1</span>',
|
|
7368
|
+
);
|
|
7369
|
+
},
|
|
7370
|
+
|
|
7371
|
+
/** Render the 540×800 card HTML for a given note + template key.
|
|
7372
|
+
* All four templates share the SAME inner data slots — kicker,
|
|
7373
|
+
* quote, byline, watermark, stamp — so this single function
|
|
7374
|
+
* works for all of them; CSS handles the visual divergence. */
|
|
7375
|
+
renderShareCardHtml(n, templateKey) {
|
|
7376
|
+
const tpl = templateKey || this.DEFAULT_SHARE_CARD_TEMPLATE;
|
|
7377
|
+
const quote = String(n.quoteText || "").trim();
|
|
7378
|
+
const author = (n.authorName || "Director").trim();
|
|
7379
|
+
const roomNum = n.roomNumber != null ? `#${String(n.roomNumber).padStart(3, "0")}` : "";
|
|
7380
|
+
const stampDate = n.createdAt
|
|
7381
|
+
? new Date(n.createdAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" })
|
|
7382
|
+
: "";
|
|
7383
|
+
const esc = (s) => this.escape(String(s || ""));
|
|
7384
|
+
|
|
7385
|
+
// Template-specific markup · each template needs a slightly
|
|
7386
|
+
// different DOM (8-bit scene for "boardroom", quotation
|
|
7387
|
+
// glyph for "editorial", ASCII frame for "terminal", top
|
|
7388
|
+
// stripe for "magazine"). Watermark on every template is
|
|
7389
|
+
// the domain `Privateboard.ai` (lowercased / capitalised
|
|
7390
|
+
// by the template's text-transform).
|
|
7391
|
+
const DOMAIN = "Privateboard.ai";
|
|
7392
|
+
|
|
7393
|
+
// Quote font-size · scaled by char count so long quotes (up
|
|
7394
|
+
// to 200 chars) shrink to fit rather than getting truncated.
|
|
7395
|
+
const qLen = quote.length;
|
|
7396
|
+
const qFont = this.shareCardQuoteSize(tpl, qLen);
|
|
7397
|
+
const qStyle = `font-size: ${qFont}px;`;
|
|
7398
|
+
|
|
7399
|
+
if (tpl === "boardroom") {
|
|
7400
|
+
// 8-bit Dribbble-style card · striped sunset sky (painted by
|
|
7401
|
+
// CSS) wrapped on every side by an inline-SVG decoration
|
|
7402
|
+
// layer (pixel moon, scattered stars, drifting clouds,
|
|
7403
|
+
// floating balloons). The meeting table + chairs are
|
|
7404
|
+
// intentionally absent — the composition reads as a sunset
|
|
7405
|
+
// poster + system message in a chunky pixel-art dialog box.
|
|
7406
|
+
// Every rect carries an explicit fill so the html-to-image
|
|
7407
|
+
// export keeps colours stable across browsers.
|
|
7408
|
+
const star = (x, y, c = "#FFF6D9") => `<rect x="${x}" y="${y}" width="2" height="2" fill="${c}"/>`;
|
|
7409
|
+
const tinyStar = (x, y, c = "#FFE8A8") => `<rect x="${x}" y="${y}" width="1" height="1" fill="${c}"/>`;
|
|
7410
|
+
// Pixel cloud · 1px-thinner cap row on top + main row +
|
|
7411
|
+
// 1-row peach underbelly shadow. Sky-bands only.
|
|
7412
|
+
const cloud = (x, y) => `
|
|
7413
|
+
<rect x="${x + 4}" y="${y - 2}" width="10" height="2" fill="#FFF6D9"/>
|
|
7414
|
+
<rect x="${x}" y="${y}" width="18" height="4" fill="#FFF6D9"/>
|
|
7415
|
+
<rect x="${x + 2}" y="${y + 4}" width="14" height="2" fill="#F4C078"/>
|
|
7416
|
+
`;
|
|
7417
|
+
const bigCloud = (x, y) => `
|
|
7418
|
+
<rect x="${x + 6}" y="${y - 4}" width="14" height="2" fill="#FFF6D9"/>
|
|
7419
|
+
<rect x="${x + 2}" y="${y - 2}" width="22" height="2" fill="#FFF6D9"/>
|
|
7420
|
+
<rect x="${x}" y="${y}" width="26" height="4" fill="#FFF6D9"/>
|
|
7421
|
+
<rect x="${x + 2}" y="${y + 4}" width="22" height="2" fill="#F4C078"/>
|
|
7422
|
+
`;
|
|
7423
|
+
// Pixel grass tuft · 7-blade upgraded tuft in two greens
|
|
7424
|
+
// with deeper roots, taller blades, and a wider ground-line.
|
|
7425
|
+
// Roughly 13×8 — about 2× the previous tuft.
|
|
7426
|
+
const grass = (x, y) => `
|
|
7427
|
+
<rect x="${x + 2}" y="${y - 4}" width="1" height="4" fill="#2A4F1D"/>
|
|
7428
|
+
<rect x="${x}" y="${y - 1}" width="2" height="3" fill="#3D6E2F"/>
|
|
7429
|
+
<rect x="${x + 3}" y="${y - 5}" width="1" height="5" fill="#2A4F1D"/>
|
|
7430
|
+
<rect x="${x + 5}" y="${y - 3}" width="1" height="3" fill="#3D6E2F"/>
|
|
7431
|
+
<rect x="${x + 4}" y="${y}" width="2" height="3" fill="#3D6E2F"/>
|
|
7432
|
+
<rect x="${x + 7}" y="${y - 4}" width="1" height="4" fill="#2A4F1D"/>
|
|
7433
|
+
<rect x="${x + 8}" y="${y - 1}" width="2" height="3" fill="#3D6E2F"/>
|
|
7434
|
+
<rect x="${x + 10}" y="${y - 3}" width="1" height="3" fill="#2A4F1D"/>
|
|
7435
|
+
<rect x="${x + 12}" y="${y - 2}" width="1" height="2" fill="#3D6E2F"/>
|
|
7436
|
+
<rect x="${x + 1}" y="${y + 3}" width="11" height="1" fill="#6BAA48"/>
|
|
7437
|
+
`;
|
|
7438
|
+
// Wider grass patch · 14-blade tuft for "tall grass clumps"
|
|
7439
|
+
// along the river bank. Reads as a thicker, denser growth.
|
|
7440
|
+
const grassWide = (x, y) => `
|
|
7441
|
+
<rect x="${x + 2}" y="${y - 5}" width="1" height="5" fill="#2A4F1D"/>
|
|
7442
|
+
<rect x="${x}" y="${y - 1}" width="2" height="3" fill="#3D6E2F"/>
|
|
7443
|
+
<rect x="${x + 3}" y="${y - 6}" width="1" height="6" fill="#2A4F1D"/>
|
|
7444
|
+
<rect x="${x + 5}" y="${y - 4}" width="1" height="4" fill="#3D6E2F"/>
|
|
7445
|
+
<rect x="${x + 4}" y="${y - 1}" width="2" height="3" fill="#3D6E2F"/>
|
|
7446
|
+
<rect x="${x + 7}" y="${y - 5}" width="1" height="5" fill="#2A4F1D"/>
|
|
7447
|
+
<rect x="${x + 8}" y="${y - 2}" width="2" height="4" fill="#3D6E2F"/>
|
|
7448
|
+
<rect x="${x + 10}" y="${y - 4}" width="1" height="4" fill="#2A4F1D"/>
|
|
7449
|
+
<rect x="${x + 12}" y="${y - 1}" width="2" height="3" fill="#3D6E2F"/>
|
|
7450
|
+
<rect x="${x + 14}" y="${y - 3}" width="1" height="3" fill="#2A4F1D"/>
|
|
7451
|
+
<rect x="${x + 16}" y="${y - 2}" width="1" height="2" fill="#3D6E2F"/>
|
|
7452
|
+
<rect x="${x + 17}" y="${y - 4}" width="1" height="4" fill="#2A4F1D"/>
|
|
7453
|
+
<rect x="${x + 1}" y="${y + 3}" width="17" height="1" fill="#6BAA48"/>
|
|
7454
|
+
`;
|
|
7455
|
+
// Pixel dirt patch · bare earth showing through the grass.
|
|
7456
|
+
// Four-tone (light top / mid body / shadow bottom / specks)
|
|
7457
|
+
// gives the patch readable 8-bit texture at small sizes.
|
|
7458
|
+
const dirtPatch = (x, y) => `
|
|
7459
|
+
<rect x="${x}" y="${y}" width="14" height="1" fill="#8A6A48"/>
|
|
7460
|
+
<rect x="${x}" y="${y + 1}" width="16" height="2" fill="#6A4A2C"/>
|
|
7461
|
+
<rect x="${x}" y="${y + 3}" width="14" height="2" fill="#5A3A22"/>
|
|
7462
|
+
<rect x="${x + 2}" y="${y + 5}" width="10" height="1" fill="#4A2E18"/>
|
|
7463
|
+
<rect x="${x + 4}" y="${y + 1}" width="2" height="1" fill="#A0814F"/>
|
|
7464
|
+
<rect x="${x + 9}" y="${y + 2}" width="2" height="1" fill="#A0814F"/>
|
|
7465
|
+
`;
|
|
7466
|
+
const dirtPatchBig = (x, y) => `
|
|
7467
|
+
<rect x="${x + 1}" y="${y}" width="22" height="1" fill="#8A6A48"/>
|
|
7468
|
+
<rect x="${x}" y="${y + 1}" width="26" height="2" fill="#6A4A2C"/>
|
|
7469
|
+
<rect x="${x}" y="${y + 3}" width="26" height="2" fill="#5A3A22"/>
|
|
7470
|
+
<rect x="${x + 2}" y="${y + 5}" width="22" height="2" fill="#4A2E18"/>
|
|
7471
|
+
<rect x="${x + 4}" y="${y + 7}" width="18" height="1" fill="#3A2410"/>
|
|
7472
|
+
<rect x="${x + 5}" y="${y + 1}" width="3" height="1" fill="#A0814F"/>
|
|
7473
|
+
<rect x="${x + 14}" y="${y + 2}" width="2" height="1" fill="#A0814F"/>
|
|
7474
|
+
<rect x="${x + 20}" y="${y + 4}" width="2" height="1" fill="#A0814F"/>
|
|
7475
|
+
<rect x="${x + 8}" y="${y + 4}" width="1" height="1" fill="#8A6A48"/>
|
|
7476
|
+
`;
|
|
7477
|
+
// Top-down pixel stones · much bigger than before, with
|
|
7478
|
+
// a round-blob silhouette (stepped rows that wrap around
|
|
7479
|
+
// a domed body), brighter centre (sun catching the top
|
|
7480
|
+
// of the rounded stone), darker rim along the bottom
|
|
7481
|
+
// edge, and a soft ground drop-shadow that reads as
|
|
7482
|
+
// "looking straight down at a chunky rock". Three sizes.
|
|
7483
|
+
const stoneSmall = (x, y) => `
|
|
7484
|
+
<!-- Shadow beneath -->
|
|
7485
|
+
<rect x="${x + 2}" y="${y + 14}" width="16" height="2" fill="#1B0A35" opacity="0.28"/>
|
|
7486
|
+
<!-- Mid-tone body (round-blob silhouette) -->
|
|
7487
|
+
<rect x="${x + 4}" y="${y}" width="12" height="2" fill="#7A6F62"/>
|
|
7488
|
+
<rect x="${x + 2}" y="${y + 2}" width="16" height="2" fill="#7A6F62"/>
|
|
7489
|
+
<rect x="${x}" y="${y + 4}" width="20" height="6" fill="#7A6F62"/>
|
|
7490
|
+
<rect x="${x + 2}" y="${y + 10}" width="16" height="2" fill="#7A6F62"/>
|
|
7491
|
+
<rect x="${x + 4}" y="${y + 12}" width="12" height="2" fill="#7A6F62"/>
|
|
7492
|
+
<!-- Lighter top-center (sun catches dome) -->
|
|
7493
|
+
<rect x="${x + 6}" y="${y + 2}" width="8" height="2" fill="#9F9388"/>
|
|
7494
|
+
<rect x="${x + 4}" y="${y + 4}" width="12" height="4" fill="#9F9388"/>
|
|
7495
|
+
<rect x="${x + 6}" y="${y + 8}" width="8" height="2" fill="#9F9388"/>
|
|
7496
|
+
<!-- Brightest specular spot -->
|
|
7497
|
+
<rect x="${x + 7}" y="${y + 4}" width="4" height="2" fill="#B5A998"/>
|
|
7498
|
+
<!-- Bottom rim shadow -->
|
|
7499
|
+
<rect x="${x + 4}" y="${y + 12}" width="12" height="1" fill="#4A4038"/>
|
|
7500
|
+
`;
|
|
7501
|
+
const stoneMed = (x, y) => `
|
|
7502
|
+
<!-- Shadow beneath -->
|
|
7503
|
+
<rect x="${x + 4}" y="${y + 20}" width="24" height="2" fill="#1B0A35" opacity="0.30"/>
|
|
7504
|
+
<rect x="${x + 6}" y="${y + 22}" width="20" height="1" fill="#1B0A35" opacity="0.18"/>
|
|
7505
|
+
<!-- Mid-tone body -->
|
|
7506
|
+
<rect x="${x + 6}" y="${y}" width="20" height="2" fill="#7A6F62"/>
|
|
7507
|
+
<rect x="${x + 3}" y="${y + 2}" width="26" height="2" fill="#7A6F62"/>
|
|
7508
|
+
<rect x="${x + 1}" y="${y + 4}" width="30" height="2" fill="#7A6F62"/>
|
|
7509
|
+
<rect x="${x}" y="${y + 6}" width="32" height="8" fill="#7A6F62"/>
|
|
7510
|
+
<rect x="${x + 1}" y="${y + 14}" width="30" height="2" fill="#7A6F62"/>
|
|
7511
|
+
<rect x="${x + 3}" y="${y + 16}" width="26" height="2" fill="#7A6F62"/>
|
|
7512
|
+
<rect x="${x + 6}" y="${y + 18}" width="20" height="2" fill="#7A6F62"/>
|
|
7513
|
+
<!-- Lighter top-centre dome -->
|
|
7514
|
+
<rect x="${x + 9}" y="${y + 2}" width="14" height="2" fill="#9F9388"/>
|
|
7515
|
+
<rect x="${x + 6}" y="${y + 4}" width="20" height="2" fill="#9F9388"/>
|
|
7516
|
+
<rect x="${x + 4}" y="${y + 6}" width="24" height="4" fill="#9F9388"/>
|
|
7517
|
+
<rect x="${x + 6}" y="${y + 10}" width="20" height="2" fill="#9F9388"/>
|
|
7518
|
+
<!-- Brightest specular spot (sun) -->
|
|
7519
|
+
<rect x="${x + 11}" y="${y + 5}" width="10" height="3" fill="#B5A998"/>
|
|
7520
|
+
<!-- Bottom rim shadow -->
|
|
7521
|
+
<rect x="${x + 4}" y="${y + 16}" width="24" height="1" fill="#5A5048"/>
|
|
7522
|
+
<rect x="${x + 7}" y="${y + 18}" width="18" height="1" fill="#4A4038"/>
|
|
7523
|
+
`;
|
|
7524
|
+
const stoneLarge = (x, y) => `
|
|
7525
|
+
<!-- Shadow beneath (wider for the bigger stone) -->
|
|
7526
|
+
<rect x="${x + 6}" y="${y + 28}" width="36" height="2" fill="#1B0A35" opacity="0.32"/>
|
|
7527
|
+
<rect x="${x + 8}" y="${y + 30}" width="32" height="1" fill="#1B0A35" opacity="0.20"/>
|
|
7528
|
+
<!-- Mid-tone body (round-blob silhouette, ~46×30) -->
|
|
7529
|
+
<rect x="${x + 10}" y="${y}" width="26" height="2" fill="#7A6F62"/>
|
|
7530
|
+
<rect x="${x + 6}" y="${y + 2}" width="34" height="2" fill="#7A6F62"/>
|
|
7531
|
+
<rect x="${x + 3}" y="${y + 4}" width="40" height="2" fill="#7A6F62"/>
|
|
7532
|
+
<rect x="${x + 1}" y="${y + 6}" width="44" height="2" fill="#7A6F62"/>
|
|
7533
|
+
<rect x="${x}" y="${y + 8}" width="46" height="10" fill="#7A6F62"/>
|
|
7534
|
+
<rect x="${x + 1}" y="${y + 18}" width="44" height="2" fill="#7A6F62"/>
|
|
7535
|
+
<rect x="${x + 3}" y="${y + 20}" width="40" height="2" fill="#7A6F62"/>
|
|
7536
|
+
<rect x="${x + 6}" y="${y + 22}" width="34" height="2" fill="#7A6F62"/>
|
|
7537
|
+
<rect x="${x + 10}" y="${y + 24}" width="26" height="2" fill="#7A6F62"/>
|
|
7538
|
+
<!-- Lighter top-centre dome (sun catches the round top) -->
|
|
7539
|
+
<rect x="${x + 14}" y="${y + 2}" width="18" height="2" fill="#9F9388"/>
|
|
7540
|
+
<rect x="${x + 10}" y="${y + 4}" width="26" height="2" fill="#9F9388"/>
|
|
7541
|
+
<rect x="${x + 6}" y="${y + 6}" width="34" height="2" fill="#9F9388"/>
|
|
7542
|
+
<rect x="${x + 4}" y="${y + 8}" width="38" height="6" fill="#9F9388"/>
|
|
7543
|
+
<rect x="${x + 6}" y="${y + 14}" width="34" height="2" fill="#9F9388"/>
|
|
7544
|
+
<!-- Brightest specular spot -->
|
|
7545
|
+
<rect x="${x + 16}" y="${y + 6}" width="14" height="4" fill="#B5A998"/>
|
|
7546
|
+
<rect x="${x + 18}" y="${y + 10}" width="10" height="2" fill="#C7BDB0"/>
|
|
7547
|
+
<!-- Bottom rim shadow (gives the stone weight) -->
|
|
7548
|
+
<rect x="${x + 6}" y="${y + 20}" width="34" height="1" fill="#5A5048"/>
|
|
7549
|
+
<rect x="${x + 10}" y="${y + 22}" width="26" height="1" fill="#4A4038"/>
|
|
7550
|
+
<rect x="${x + 14}" y="${y + 24}" width="18" height="1" fill="#3A302A"/>
|
|
7551
|
+
`;
|
|
7552
|
+
// Top-down meandering river · a wide cyan ribbon that
|
|
7553
|
+
// S-curves across the bottom band. Coordinates tuned for
|
|
7554
|
+
// the 540×750 card (cream ground starts ≈ y=428): river
|
|
7555
|
+
// body spans roughly y=660-715. Width varies 30-44px at
|
|
7556
|
+
// different points to mimic a real meandering stream.
|
|
7557
|
+
const meanderingRiver = `
|
|
7558
|
+
<!-- Body · solid cyan. The whole scene group (river +
|
|
7559
|
+
stones + grass + dirt) was bumped 100px up from
|
|
7560
|
+
the previous layout so the watermark / stamp at
|
|
7561
|
+
the bottom have a generous cream footer above them. -->
|
|
7562
|
+
<path d="M -10 583
|
|
7563
|
+
C 80 571, 180 603, 260 583
|
|
7564
|
+
C 340 563, 420 595, 550 575
|
|
7565
|
+
L 550 619
|
|
7566
|
+
C 420 631, 340 607, 260 627
|
|
7567
|
+
C 180 647, 80 621, -10 629 Z"
|
|
7568
|
+
fill="#5BC0EB" shape-rendering="geometricPrecision"/>
|
|
7569
|
+
<!-- Top edge highlight (lighter ripple) -->
|
|
7570
|
+
<path d="M -10 585
|
|
7571
|
+
C 80 573, 180 605, 260 585
|
|
7572
|
+
C 340 565, 420 597, 550 577"
|
|
7573
|
+
stroke="#9ADEFA" stroke-width="2" fill="none"
|
|
7574
|
+
shape-rendering="geometricPrecision"/>
|
|
7575
|
+
<!-- Bottom edge shadow (darker rim) -->
|
|
7576
|
+
<path d="M -10 627
|
|
7577
|
+
C 80 619, 180 645, 260 625
|
|
7578
|
+
C 340 605, 420 629, 550 617"
|
|
7579
|
+
stroke="#2A6FA5" stroke-width="2" fill="none"
|
|
7580
|
+
shape-rendering="geometricPrecision"/>
|
|
7581
|
+
<!-- Sparkle pixels on the water surface -->
|
|
7582
|
+
<rect x="44" y="595" width="3" height="1" fill="#FFFFFF"/>
|
|
7583
|
+
<rect x="116" y="605" width="2" height="1" fill="#FFFFFF"/>
|
|
7584
|
+
<rect x="184" y="599" width="3" height="1" fill="#FFFFFF"/>
|
|
7585
|
+
<rect x="244" y="607" width="2" height="1" fill="#FFFFFF"/>
|
|
7586
|
+
<rect x="312" y="591" width="3" height="1" fill="#FFFFFF"/>
|
|
7587
|
+
<rect x="368" y="593" width="2" height="1" fill="#FFFFFF"/>
|
|
7588
|
+
<rect x="436" y="587" width="3" height="1" fill="#FFFFFF"/>
|
|
7589
|
+
<rect x="496" y="595" width="2" height="1" fill="#FFFFFF"/>
|
|
7590
|
+
<rect x="72" y="615" width="2" height="1" fill="#FFFFFF"/>
|
|
7591
|
+
<rect x="160" y="619" width="3" height="1" fill="#FFFFFF"/>
|
|
7592
|
+
<rect x="284" y="615" width="2" height="1" fill="#FFFFFF"/>
|
|
7593
|
+
<rect x="396" y="611" width="3" height="1" fill="#FFFFFF"/>
|
|
7594
|
+
`;
|
|
7595
|
+
// Sky decoration layer · fills the full 540×800 card so
|
|
7596
|
+
// pixel atmosphere wraps the bubble on every side. Sky
|
|
7597
|
+
// half holds the moon + stars + clouds; ground (cream)
|
|
7598
|
+
// half holds pixel stones + grass + dirt + a meandering
|
|
7599
|
+
// river.
|
|
7600
|
+
const skyDeco = `
|
|
7601
|
+
<!-- Pixel moon · 8-bit circle in upper-right, ~y=78 so
|
|
7602
|
+
it clears the top-right "Privateboard.ai" header
|
|
7603
|
+
text (which lives at y=22-34). 5 stepped rows of
|
|
7604
|
+
pixel pairs form the round silhouette; three
|
|
7605
|
+
crater highlights in a warmer cream add character. -->
|
|
7606
|
+
<g transform="translate(440 78)">
|
|
7607
|
+
<rect x="8" y="0" width="20" height="4" fill="#FFF6D9"/>
|
|
7608
|
+
<rect x="4" y="4" width="28" height="4" fill="#FFF6D9"/>
|
|
7609
|
+
<rect x="0" y="8" width="36" height="14" fill="#FFF6D9"/>
|
|
7610
|
+
<rect x="4" y="22" width="28" height="4" fill="#FFF6D9"/>
|
|
7611
|
+
<rect x="8" y="26" width="20" height="4" fill="#FFF6D9"/>
|
|
7612
|
+
<rect x="14" y="10" width="3" height="3" fill="#F4D898"/>
|
|
7613
|
+
<rect x="22" y="14" width="2" height="2" fill="#F4D898"/>
|
|
7614
|
+
<rect x="10" y="18" width="2" height="2" fill="#F4D898"/>
|
|
7615
|
+
</g>
|
|
7616
|
+
<!-- Star field · denser in deep purple bands, thinning
|
|
7617
|
+
as the sky warms. Mix of 2×2 cream pixels and 1×1
|
|
7618
|
+
amber pixels for depth. Stars near the top-right
|
|
7619
|
+
text bounds (y≈22-34, x≈400-512) were moved down
|
|
7620
|
+
so they don't print on the "Privateboard.ai" label. -->
|
|
7621
|
+
${star(40, 50)}${star(112, 58)}${star(180, 38)}${star(252, 30)}${star(304, 10)}${star(212, 64)}
|
|
7622
|
+
${star(72, 90)}${star(160, 84)}${star(220, 110)}${star(316, 64)}${star(376, 116)}${star(36, 98)}
|
|
7623
|
+
${star(140, 116)}${star(264, 102)}${star(420, 132)}${star(196, 138)}${star(348, 142)}
|
|
7624
|
+
${tinyStar(60, 40)}${tinyStar(168, 22)}${tinyStar(232, 50)}${tinyStar(340, 50)}${tinyStar(414, 116)}
|
|
7625
|
+
${tinyStar(96, 76)}${tinyStar(204, 104)}${tinyStar(280, 130)}${tinyStar(388, 124)}
|
|
7626
|
+
${tinyStar(56, 156)}${tinyStar(132, 168)}${tinyStar(420, 178)}
|
|
7627
|
+
<!-- Clouds · sky bands only (rose / coral around y≈170).
|
|
7628
|
+
The two clouds that previously sat at y=232 / y=244
|
|
7629
|
+
were dropped — after the scene group was shifted up,
|
|
7630
|
+
the bubble's top edge (y=200) covered them entirely. -->
|
|
7631
|
+
${bigCloud(48, 168)}
|
|
7632
|
+
${cloud(372, 158)}
|
|
7633
|
+
${cloud(212, 178)}
|
|
7634
|
+
<!-- Ground · meandering top-down river curving across
|
|
7635
|
+
the warm cream band, plus chunky stones on both
|
|
7636
|
+
banks, denser grass tufts (mix of regular + wide),
|
|
7637
|
+
and scattered dirt patches breaking up the grass.
|
|
7638
|
+
The whole scene group was shifted 100px UP from
|
|
7639
|
+
the previous layout so the watermark + stamp at
|
|
7640
|
+
the bottom of the card have a clean cream "footer"
|
|
7641
|
+
above them. -->
|
|
7642
|
+
${meanderingRiver}
|
|
7643
|
+
<!-- Stones · spread across both banks. -->
|
|
7644
|
+
${stoneLarge(20, 535)}
|
|
7645
|
+
${stoneMed(232, 568)}
|
|
7646
|
+
${stoneSmall(376, 572)}
|
|
7647
|
+
${stoneMed(444, 633)}
|
|
7648
|
+
${stoneSmall(96, 639)}
|
|
7649
|
+
${stoneSmall(312, 639)}
|
|
7650
|
+
<!-- Grass on the upper bank, woven between the stones. -->
|
|
7651
|
+
${grass(76, 562)}
|
|
7652
|
+
${grass(140, 566)}
|
|
7653
|
+
${grass(296, 570)}
|
|
7654
|
+
${grass(420, 572)}
|
|
7655
|
+
${grass(496, 566)}
|
|
7656
|
+
${grass(180, 578)}
|
|
7657
|
+
<!-- Grass on the lower bank just below the river. -->
|
|
7658
|
+
${grass(40, 649)}
|
|
7659
|
+
${grass(180, 651)}
|
|
7660
|
+
${grass(220, 651)}
|
|
7661
|
+
${grass(360, 653)}
|
|
7662
|
+
${grass(500, 651)}
|
|
7663
|
+
<!-- Bigger grass clumps + dirt patches in the strip
|
|
7664
|
+
just below the lower-bank stones. -->
|
|
7665
|
+
${dirtPatchBig(58, 658)}
|
|
7666
|
+
${dirtPatch(190, 662)}
|
|
7667
|
+
${dirtPatch(330, 660)}
|
|
7668
|
+
${dirtPatchBig(402, 658)}
|
|
7669
|
+
${grassWide(140, 666)}
|
|
7670
|
+
${grassWide(290, 666)}
|
|
7671
|
+
${grassWide(430, 666)}
|
|
7672
|
+
<!-- Extra dirt patches scattered on the upper bank. -->
|
|
7673
|
+
${dirtPatch(180, 584)}
|
|
7674
|
+
${dirtPatch(326, 578)}
|
|
7675
|
+
`;
|
|
7676
|
+
return `
|
|
7677
|
+
<div class="share-card" data-share-template="boardroom">
|
|
7678
|
+
<div class="sc-header">
|
|
7679
|
+
<span>◆ PRIVATEBOARD<span class="sc-heart">♥</span></span>
|
|
7680
|
+
<span class="sc-header-right">${esc(DOMAIN)}</span>
|
|
7681
|
+
</div>
|
|
7682
|
+
<svg class="sc-sky-deco" viewBox="0 0 540 800" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
7683
|
+
${skyDeco}
|
|
7684
|
+
</svg>
|
|
7685
|
+
<div class="sc-bubble">
|
|
7686
|
+
<span class="sc-nail tl" aria-hidden="true"></span>
|
|
7687
|
+
<span class="sc-nail tr" aria-hidden="true"></span>
|
|
7688
|
+
<span class="sc-nail bl" aria-hidden="true"></span>
|
|
7689
|
+
<span class="sc-nail br" aria-hidden="true"></span>
|
|
7690
|
+
${n.roomSubject ? `<div class="sc-bubble-meta">// re: ${this.wrapCjk(esc(String(n.roomSubject).trim()))}</div>` : ""}
|
|
7691
|
+
<p class="sc-bubble-text" style="${qStyle}">${this.wrapCjk(esc(quote))}</p>
|
|
7692
|
+
</div>
|
|
7693
|
+
<div class="sc-byline">${this.wrapCjk(esc(`被蒸馏的 ${author}`))}</div>
|
|
7694
|
+
<div class="share-card-watermark">${esc(DOMAIN)}</div>
|
|
7695
|
+
<div class="share-card-stamp">${esc(stampDate)}</div>
|
|
7696
|
+
</div>
|
|
7697
|
+
`;
|
|
7698
|
+
}
|
|
7699
|
+
if (tpl === "editorial") {
|
|
7700
|
+
return `
|
|
7701
|
+
<div class="share-card" data-share-template="editorial">
|
|
7702
|
+
<div>
|
|
7703
|
+
<div class="sc-quotemark">“</div>
|
|
7704
|
+
<p class="sc-quote" style="${qStyle}">${this.wrapCjk(esc(quote))}</p>
|
|
7705
|
+
</div>
|
|
7706
|
+
<div class="sc-byline">— <strong>${this.wrapCjk(esc(author))}</strong>${roomNum ? ` · room ${esc(roomNum)}` : ""}</div>
|
|
7707
|
+
<div class="share-card-watermark">${esc(DOMAIN)}</div>
|
|
7708
|
+
<div class="share-card-stamp">${esc(stampDate)}</div>
|
|
7709
|
+
</div>
|
|
7710
|
+
`;
|
|
7711
|
+
}
|
|
7712
|
+
if (tpl === "terminal") {
|
|
7713
|
+
return `
|
|
7714
|
+
<div class="share-card" data-share-template="terminal">
|
|
7715
|
+
<div class="sc-frame">
|
|
7716
|
+
<div class="sc-prompt">$ cat note.txt</div>
|
|
7717
|
+
<p class="sc-quote" style="${qStyle}">${this.wrapCjk(esc(quote))}</p>
|
|
7718
|
+
<div class="sc-byline">> attributed to: <strong>${this.wrapCjk(esc(author))}</strong>${roomNum ? ` <span class="sc-byline-dim">[room ${esc(roomNum)}]</span>` : ""}</div>
|
|
7719
|
+
</div>
|
|
7720
|
+
<div class="share-card-watermark">${esc(DOMAIN)}</div>
|
|
7721
|
+
<div class="share-card-stamp">${esc(stampDate)}</div>
|
|
7722
|
+
</div>
|
|
7723
|
+
`;
|
|
7724
|
+
}
|
|
7725
|
+
// magazine (default fallthrough)
|
|
7726
|
+
return `
|
|
7727
|
+
<div class="share-card" data-share-template="magazine">
|
|
7728
|
+
<div class="sc-stripe"></div>
|
|
7729
|
+
<div class="sc-kicker">From the boardroom</div>
|
|
7730
|
+
<p class="sc-quote" style="${qStyle}">${this.wrapCjk(esc(quote))}</p>
|
|
7731
|
+
<div class="sc-byline">
|
|
7732
|
+
<span><strong>${this.wrapCjk(esc(author))}</strong></span>
|
|
7733
|
+
<span>${roomNum ? `room ${esc(roomNum)}` : ""}${stampDate ? (roomNum ? " · " : "") + esc(stampDate) : ""}</span>
|
|
7734
|
+
</div>
|
|
7735
|
+
<div class="share-card-watermark">${esc(DOMAIN)}</div>
|
|
7736
|
+
</div>
|
|
7737
|
+
`;
|
|
7738
|
+
},
|
|
7739
|
+
|
|
7740
|
+
/** Lazy-load html-to-image from CDN. Same loader pattern as
|
|
7741
|
+
* `public/magazine.html`'s `ensureHtmlToImage` so the All Notes
|
|
7742
|
+
* page doesn't ship the lib unless the user actually exports. */
|
|
7743
|
+
_h2iLoaded: null,
|
|
7744
|
+
async ensureShareCardHtmlToImage() {
|
|
7745
|
+
if (window.htmlToImage) return;
|
|
7746
|
+
if (!this._h2iLoaded) {
|
|
7747
|
+
this._h2iLoaded = new Promise((res, rej) => {
|
|
7748
|
+
const s = document.createElement("script");
|
|
7749
|
+
s.src = "https://cdn.jsdelivr.net/npm/html-to-image@1.11.13/dist/html-to-image.min.js";
|
|
7750
|
+
s.onload = res;
|
|
7751
|
+
s.onerror = rej;
|
|
7752
|
+
document.head.appendChild(s);
|
|
7753
|
+
});
|
|
7754
|
+
}
|
|
7755
|
+
await this._h2iLoaded;
|
|
7756
|
+
},
|
|
7757
|
+
|
|
7758
|
+
/** Capture the current preview at native 540×675 and save it as
|
|
7759
|
+
* a 1080×1350 PNG. The visible preview is scaled down for fit;
|
|
7760
|
+
* we capture the un-scaled inner card so the export resolution
|
|
7761
|
+
* is independent of viewport width. Errors surface as a soft
|
|
7762
|
+
* alert + console.warn — same pattern as magazine/ppt exports. */
|
|
7763
|
+
async downloadShareCard() {
|
|
7764
|
+
if (!this._shareCardNote) return;
|
|
7765
|
+
const btn = document.querySelector("[data-share-card-download]");
|
|
7766
|
+
const overlay = document.getElementById("share-card-overlay");
|
|
7767
|
+
if (!overlay) return;
|
|
7768
|
+
try {
|
|
7769
|
+
if (btn) { btn.disabled = true; btn.textContent = "rendering…"; }
|
|
7770
|
+
await this.ensureShareCardHtmlToImage();
|
|
7771
|
+
if (document.fonts && document.fonts.ready) {
|
|
7772
|
+
try { await document.fonts.ready; } catch { /* best-effort */ }
|
|
7773
|
+
}
|
|
7774
|
+
// Find the live card element (the inner-most .share-card div
|
|
7775
|
+
// inside the preview, NOT the scaling wrapper). Capturing the
|
|
7776
|
+
// 540×750 card directly at pixelRatio: 2 gives a 1080×1500
|
|
7777
|
+
// PNG that ignores the cosmetic scale applied to the preview.
|
|
7778
|
+
const card = overlay.querySelector(".share-card");
|
|
7779
|
+
if (!card) throw new Error("share card not mounted");
|
|
7780
|
+
const dataUrl = await window.htmlToImage.toPng(card, {
|
|
7781
|
+
pixelRatio: 2,
|
|
7782
|
+
cacheBust: true,
|
|
7783
|
+
width: 540,
|
|
7784
|
+
height: 800,
|
|
7785
|
+
canvasWidth: 540,
|
|
7786
|
+
canvasHeight: 800,
|
|
7787
|
+
style: { transform: "none", margin: "0", width: "540px", height: "800px" },
|
|
7788
|
+
});
|
|
7789
|
+
const slug = (this._shareCardNote.authorName || "note")
|
|
7790
|
+
.toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 40) || "note";
|
|
7791
|
+
const a = document.createElement("a");
|
|
7792
|
+
a.download = `privateboard-${slug}-${(this._shareCardNote.id || "").slice(0, 6)}.png`;
|
|
7793
|
+
a.href = dataUrl;
|
|
7794
|
+
a.click();
|
|
7795
|
+
} catch (e) {
|
|
7796
|
+
console.warn("[share-card] PNG export failed:", e);
|
|
7797
|
+
alert("PNG export failed — check the browser console for details.");
|
|
7798
|
+
} finally {
|
|
7799
|
+
if (btn) {
|
|
7800
|
+
btn.disabled = false;
|
|
7801
|
+
btn.innerHTML = `<span aria-hidden="true">↓</span><span>Download PNG</span>`;
|
|
7802
|
+
}
|
|
7803
|
+
}
|
|
7804
|
+
},
|
|
7805
|
+
|
|
6981
7806
|
/** DELETE /api/notes/:id · drops a saved excerpt from the index.
|
|
6982
7807
|
* Confirmation is light because the action is reversible only by
|
|
6983
7808
|
* re-saving the same passage; we keep the prompt as a single
|
|
6984
7809
|
* click-confirm rather than a full overlay. */
|
|
6985
7810
|
async deleteNoteAt(id) {
|
|
6986
7811
|
if (!id) return;
|
|
6987
|
-
if (!confirm("
|
|
7812
|
+
if (!confirm("Remove this note from your saved list? You can re-save it any time by highlighting the same passage in the room.")) return;
|
|
6988
7813
|
try {
|
|
6989
7814
|
const r = await fetch("/api/notes/" + encodeURIComponent(id), { method: "DELETE" });
|
|
6990
7815
|
if (!r.ok) {
|
|
@@ -7880,8 +8705,31 @@
|
|
|
7880
8705
|
// unmistakeably a select) without taking up visual real estate.
|
|
7881
8706
|
const toneLbl = this._t("cmp_tone_label");
|
|
7882
8707
|
const intensityLbl = this._t("cmp_intensity_label");
|
|
7883
|
-
// Starter grid · 2-col responsive cards.
|
|
7884
|
-
|
|
8708
|
+
// Starter grid · 2-col responsive cards. Two parallel
|
|
8709
|
+
// pools render in the same `.cmp-starters-grid`:
|
|
8710
|
+
// 1. Topic recommendations (newest-first, paged, visible
|
|
8711
|
+
// list, capped at the 6 the server keeps). Each card
|
|
8712
|
+
// carries a synthesiser-generated tag (e.g.
|
|
8713
|
+
// "strategy") in the left column. Clicking populates
|
|
8714
|
+
// the composer with the rec's subject + attaches its
|
|
8715
|
+
// seedContext snippets so the room opens grounded in
|
|
8716
|
+
// the source material.
|
|
8717
|
+
// 2. Legacy hardcoded starters from window.BOARDROOM_STARTERS.
|
|
8718
|
+
// Show only when there are no recommendations yet (or
|
|
8719
|
+
// when recs are still loading on first paint) so a
|
|
8720
|
+
// brand-new user sees something useful in the tray.
|
|
8721
|
+
// Server keeps at most 6 rows — defensive slice covers
|
|
8722
|
+
// any stale cache that pre-dates the "always 6" rule.
|
|
8723
|
+
const recs = (this.topicRecs.items || []).slice(0, 6);
|
|
8724
|
+
// Card markup lives in `topicRecCardHtml` so the
|
|
8725
|
+
// initial render path and any later append paths can't
|
|
8726
|
+
// drift in shape / attributes.
|
|
8727
|
+
const recCards = recs.map((rec) => this.topicRecCardHtml(rec)).join("");
|
|
8728
|
+
|
|
8729
|
+
const showLegacyStarters = recs.length === 0;
|
|
8730
|
+
const starters = showLegacyStarters && Array.isArray(window.BOARDROOM_STARTERS)
|
|
8731
|
+
? window.BOARDROOM_STARTERS
|
|
8732
|
+
: [];
|
|
7885
8733
|
const starterCards = starters.map((q, idx) => {
|
|
7886
8734
|
const tag = (q.tag || "").replace(/^\/\/\s*/, "");
|
|
7887
8735
|
return `
|
|
@@ -7892,6 +8740,47 @@
|
|
|
7892
8740
|
</button>
|
|
7893
8741
|
`;
|
|
7894
8742
|
}).join("");
|
|
8743
|
+
// Trigger card · disguised as the first row in the
|
|
8744
|
+
// starters grid, so the "recommend" action lives in the
|
|
8745
|
+
// same visual rhythm as the suggestions it produces. The
|
|
8746
|
+
// card has TWO states wired through the same DOM hooks
|
|
8747
|
+
// ([data-trec-label] / [data-trec-detail] / [data-trec-pct]
|
|
8748
|
+
// / [data-trec-bar]) so the SSE handler can patch them
|
|
8749
|
+
// in place without re-rendering the whole composer:
|
|
8750
|
+
// · IDLE (no job) · tag = "✦ discover", text = the
|
|
8751
|
+
// bilingual label, arrow = "+". Looks like a starter
|
|
8752
|
+
// but with a lime accent + dashed bottom rule.
|
|
8753
|
+
// · BUSY (job live) · tag = phase label, text =
|
|
8754
|
+
// animated dots + phase detail, arrow = "N%". A
|
|
8755
|
+
// thin lime progress bar lives along the bottom edge
|
|
8756
|
+
// and fills as the pipeline ticks.
|
|
8757
|
+
const job = this.topicRecs.job;
|
|
8758
|
+
const triggerBusy = !!job;
|
|
8759
|
+
const triggerCard = `
|
|
8760
|
+
<button type="button"
|
|
8761
|
+
class="cmp-starter cmp-recs-trigger-card${triggerBusy ? " is-busy" : ""}"
|
|
8762
|
+
data-cmp-recs-trigger
|
|
8763
|
+
data-topic-rec-progress
|
|
8764
|
+
${triggerBusy ? "disabled" : ""}
|
|
8765
|
+
title="${this.escape("找你可能感兴趣的话题")}">
|
|
8766
|
+
<div class="cmp-starter-tag" data-trec-label>${this.escape(triggerBusy ? (job.label || "starting…") : "✦ discover")}</div>
|
|
8767
|
+
<div class="cmp-starter-text">
|
|
8768
|
+
${triggerBusy
|
|
8769
|
+
? `<span class="cmp-recs-trigger-dots" aria-hidden="true"><i></i><i></i><i></i></span><span data-trec-detail>${this.escape(job.detail || "")}</span>`
|
|
8770
|
+
: `<span>找你可能感兴趣的话题</span>`}
|
|
8771
|
+
</div>
|
|
8772
|
+
<div class="cmp-starter-arrow" data-trec-pct>${this.escape(triggerBusy ? (job.pct ? `${job.pct}%` : "·") : "+")}</div>
|
|
8773
|
+
<div class="cmp-recs-trigger-bar" aria-hidden="true"><div class="cmp-recs-trigger-fill" data-trec-bar style="width: ${triggerBusy ? (job.pct || 0) : 0}%"></div></div>
|
|
8774
|
+
</button>
|
|
8775
|
+
`;
|
|
8776
|
+
|
|
8777
|
+
// Trigger card always leads; suggestions (or legacy
|
|
8778
|
+
// starters as empty-state fallback) follow.
|
|
8779
|
+
const trayCards = triggerCard + (recs.length > 0 ? recCards : starterCards);
|
|
8780
|
+
// Pagination is intentionally OFF — the server keeps only
|
|
8781
|
+
// the latest batch (6 rows). Every fresh generation wipes
|
|
8782
|
+
// the previous batch, so there's never "older" data to
|
|
8783
|
+
// page back to. The "+ N more" surface is gone.
|
|
7895
8784
|
|
|
7896
8785
|
return `
|
|
7897
8786
|
<section class="cmp">
|
|
@@ -7967,16 +8856,14 @@
|
|
|
7967
8856
|
</div>
|
|
7968
8857
|
</div>
|
|
7969
8858
|
|
|
7970
|
-
|
|
7971
|
-
<div class="cmp-starters">
|
|
7972
|
-
<
|
|
7973
|
-
|
|
7974
|
-
|
|
7975
|
-
<span class="cmp-starters-rule-line"></span>
|
|
7976
|
-
</div>
|
|
7977
|
-
<div class="cmp-starters-grid">${starterCards}</div>
|
|
8859
|
+
<div class="cmp-starters">
|
|
8860
|
+
<div class="cmp-starters-rule">
|
|
8861
|
+
<span class="cmp-starters-rule-line"></span>
|
|
8862
|
+
<span class="cmp-starters-rule-label">${this.escape(t.starterCaption)}</span>
|
|
8863
|
+
<span class="cmp-starters-rule-line"></span>
|
|
7978
8864
|
</div>
|
|
7979
|
-
|
|
8865
|
+
<div class="cmp-starters-grid">${trayCards}</div>
|
|
8866
|
+
</div>
|
|
7980
8867
|
</section>
|
|
7981
8868
|
`;
|
|
7982
8869
|
},
|
|
@@ -10575,11 +11462,16 @@
|
|
|
10575
11462
|
intensity: state.intensity,
|
|
10576
11463
|
deliveryMode: state.deliveryMode,
|
|
10577
11464
|
autoPick: useAutoPick,
|
|
11465
|
+
seedContext: state.seedContext || null,
|
|
10578
11466
|
});
|
|
10579
11467
|
// Clear the saved draft now that the room is convened — next
|
|
10580
11468
|
// visit to "+ New Room" should land on a fresh textarea, not
|
|
10581
|
-
// re-show the just-submitted subject.
|
|
11469
|
+
// re-show the just-submitted subject. Also drop the attached
|
|
11470
|
+
// seedContext — the snippets travelled into the room's
|
|
11471
|
+
// opening message; we don't want them piggy-backing on the
|
|
11472
|
+
// NEXT room the user opens.
|
|
10582
11473
|
state.subject = "";
|
|
11474
|
+
state.seedContext = null;
|
|
10583
11475
|
this.saveComposerState();
|
|
10584
11476
|
} catch (e) {
|
|
10585
11477
|
if (btn) btn.classList.remove("busy");
|
|
@@ -10587,6 +11479,242 @@
|
|
|
10587
11479
|
}
|
|
10588
11480
|
},
|
|
10589
11481
|
|
|
11482
|
+
/** Fetch the (single) page of topic recommendations · the
|
|
11483
|
+
* server keeps only the latest batch (6 rows), so one
|
|
11484
|
+
* request is the whole story. Idempotent — safe to call
|
|
11485
|
+
* from renderEmptyState() boot AND after a generation job
|
|
11486
|
+
* completes. Sets `topicRecs.loaded` so first-paint can
|
|
11487
|
+
* fall back to legacy hardcoded starters until this
|
|
11488
|
+
* resolves. */
|
|
11489
|
+
async refreshTopicRecs() {
|
|
11490
|
+
try {
|
|
11491
|
+
const r = await fetch("/api/topic-recs?limit=6");
|
|
11492
|
+
if (!r.ok) {
|
|
11493
|
+
this.topicRecs.loaded = true;
|
|
11494
|
+
this.renderEmptyState();
|
|
11495
|
+
return;
|
|
11496
|
+
}
|
|
11497
|
+
const j = await r.json();
|
|
11498
|
+
this.topicRecs.items = Array.isArray(j.items) ? j.items : [];
|
|
11499
|
+
this.topicRecs.loaded = true;
|
|
11500
|
+
// Re-render only when the user is still on the empty
|
|
11501
|
+
// (composer) state · openRoom paths have already moved
|
|
11502
|
+
// on and an unsolicited re-render would steal focus.
|
|
11503
|
+
if (!this.currentRoomId) this.renderEmptyState();
|
|
11504
|
+
} catch { /* network error · keep stale list, surface nothing */ }
|
|
11505
|
+
},
|
|
11506
|
+
|
|
11507
|
+
/** Derive a short tag from a subject string · used as the
|
|
11508
|
+
* client-side fallback when a rec row has no `tag` field
|
|
11509
|
+
* (legacy rows from before migration 035, or the rare
|
|
11510
|
+
* case where the LLM forgot to include one and the
|
|
11511
|
+
* orchestrator's safety-net path also fired). Mirrors the
|
|
11512
|
+
* orchestrator's `deriveTagFromSubject` logic so the
|
|
11513
|
+
* vocabulary stays consistent across paths. */
|
|
11514
|
+
_deriveTagFromSubject(subject) {
|
|
11515
|
+
const STOP = new Set([
|
|
11516
|
+
"the", "and", "for", "are", "you", "your", "what", "how",
|
|
11517
|
+
"why", "when", "with", "from", "this", "that", "should",
|
|
11518
|
+
"could", "would", "have", "has", "will", "into", "about",
|
|
11519
|
+
]);
|
|
11520
|
+
const words = (subject || "")
|
|
11521
|
+
.toLowerCase()
|
|
11522
|
+
.replace(/[^a-z0-9\s-]/g, " ")
|
|
11523
|
+
.split(/\s+/)
|
|
11524
|
+
.filter((w) => w.length > 2 && !STOP.has(w));
|
|
11525
|
+
return words.slice(0, 2).join(" ").slice(0, 28) || "topic";
|
|
11526
|
+
},
|
|
11527
|
+
|
|
11528
|
+
/** Build the HTML for a single topic-rec card. Used both by
|
|
11529
|
+
* `renderComposerHtml` (initial render) and `loadMoreTopicRecs`
|
|
11530
|
+
* (in-place append). Keeping the markup in one helper means
|
|
11531
|
+
* the two paths can't drift in shape or attributes. */
|
|
11532
|
+
topicRecCardHtml(rec) {
|
|
11533
|
+
// Tag priority: synthesiser-produced category > derive
|
|
11534
|
+
// from subject. We NEVER fall back to "web" / "memory"
|
|
11535
|
+
// as the visible tag — those are data-provenance tokens,
|
|
11536
|
+
// not topic categories. The `data-source` attribute still
|
|
11537
|
+
// carries the provenance for CSS to colour-tint with.
|
|
11538
|
+
const tag = (typeof rec.tag === "string" && rec.tag.trim().length > 0)
|
|
11539
|
+
? rec.tag
|
|
11540
|
+
: this._deriveTagFromSubject(rec.subject);
|
|
11541
|
+
const hint = rec.rationale || "";
|
|
11542
|
+
return `
|
|
11543
|
+
<button type="button" class="cmp-starter cmp-rec" data-cmp-rec="${this.escape(rec.id)}" data-source="${this.escape(rec.source)}">
|
|
11544
|
+
<div class="cmp-starter-tag">${this.escape(tag)}</div>
|
|
11545
|
+
<div class="cmp-starter-text">${this.escape(rec.subject || "")}</div>
|
|
11546
|
+
${hint ? `<div class="cmp-rec-hint">${this.escape(hint)}</div>` : ""}
|
|
11547
|
+
<div class="cmp-starter-arrow">→</div>
|
|
11548
|
+
</button>
|
|
11549
|
+
`;
|
|
11550
|
+
},
|
|
11551
|
+
|
|
11552
|
+
// (no `loadMoreTopicRecs` — see comments at the
|
|
11553
|
+
// pagination-removed render block above.)
|
|
11554
|
+
|
|
11555
|
+
/** Kick off a new topic-recommendation generation job and
|
|
11556
|
+
* attach an SSE stream so the composer's progress strip
|
|
11557
|
+
* ticks through phases live. On `topic-final` we refresh
|
|
11558
|
+
* the tray from the API; on error we surface the message
|
|
11559
|
+
* inline so the user understands why nothing landed. */
|
|
11560
|
+
async startTopicRecJob() {
|
|
11561
|
+
if (this.topicRecs.job) return; // already running · idempotent click
|
|
11562
|
+
// Pre-flight · API gate requires a model key. Surface the
|
|
11563
|
+
// same prompt as Convene so the user fixes it once.
|
|
11564
|
+
if (!(await this.requireModelKey())) return;
|
|
11565
|
+
try {
|
|
11566
|
+
const r = await fetch("/api/topic-recs", {
|
|
11567
|
+
method: "POST",
|
|
11568
|
+
headers: { "content-type": "application/json" },
|
|
11569
|
+
body: JSON.stringify({}),
|
|
11570
|
+
});
|
|
11571
|
+
if (!r.ok) {
|
|
11572
|
+
const j = await r.json().catch(() => ({}));
|
|
11573
|
+
alert("Couldn't start: " + (j.error || r.statusText));
|
|
11574
|
+
return;
|
|
11575
|
+
}
|
|
11576
|
+
const { jobId } = await r.json();
|
|
11577
|
+
this.topicRecs.job = {
|
|
11578
|
+
id: jobId,
|
|
11579
|
+
phase: 0,
|
|
11580
|
+
label: "starting…",
|
|
11581
|
+
pct: 0,
|
|
11582
|
+
detail: "",
|
|
11583
|
+
es: null,
|
|
11584
|
+
error: null,
|
|
11585
|
+
};
|
|
11586
|
+
if (!this.currentRoomId) this.renderEmptyState();
|
|
11587
|
+
this._attachTopicRecJobSSE(jobId);
|
|
11588
|
+
} catch (e) {
|
|
11589
|
+
alert("Couldn't start: " + (e && e.message ? e.message : e));
|
|
11590
|
+
}
|
|
11591
|
+
},
|
|
11592
|
+
|
|
11593
|
+
/** Internal · attach the EventSource for a running job and
|
|
11594
|
+
* wire each event type to the in-memory job state. */
|
|
11595
|
+
_attachTopicRecJobSSE(jobId) {
|
|
11596
|
+
const url = `/api/topic-recs/jobs/${encodeURIComponent(jobId)}/stream`;
|
|
11597
|
+
const es = new EventSource(url);
|
|
11598
|
+
this.topicRecs.job.es = es;
|
|
11599
|
+
const updateStrip = () => {
|
|
11600
|
+
const el = document.querySelector("[data-topic-rec-progress]");
|
|
11601
|
+
if (!el || !this.topicRecs.job) return;
|
|
11602
|
+
const j = this.topicRecs.job;
|
|
11603
|
+
const labelEl = el.querySelector("[data-trec-label]");
|
|
11604
|
+
if (labelEl) labelEl.textContent = j.label || "";
|
|
11605
|
+
const detailEl = el.querySelector("[data-trec-detail]");
|
|
11606
|
+
if (detailEl) detailEl.textContent = j.detail || "";
|
|
11607
|
+
const pctEl = el.querySelector("[data-trec-pct]");
|
|
11608
|
+
if (pctEl) pctEl.textContent = j.pct ? `${j.pct}%` : "·";
|
|
11609
|
+
const bar = el.querySelector("[data-trec-bar]");
|
|
11610
|
+
if (bar) bar.style.width = `${j.pct || 0}%`;
|
|
11611
|
+
};
|
|
11612
|
+
const terminate = () => {
|
|
11613
|
+
try { es.close(); } catch { /* noop */ }
|
|
11614
|
+
this.topicRecs.job = null;
|
|
11615
|
+
if (!this.currentRoomId) this.renderEmptyState();
|
|
11616
|
+
};
|
|
11617
|
+
es.addEventListener("hello", (ev) => {
|
|
11618
|
+
try {
|
|
11619
|
+
const data = JSON.parse(ev.data);
|
|
11620
|
+
if (this.topicRecs.job) {
|
|
11621
|
+
this.topicRecs.job.phase = data.currentPhase || 0;
|
|
11622
|
+
this.topicRecs.job.pct = data.progressPct || 0;
|
|
11623
|
+
}
|
|
11624
|
+
updateStrip();
|
|
11625
|
+
} catch { /* noop */ }
|
|
11626
|
+
});
|
|
11627
|
+
es.addEventListener("topic-phase-start", (ev) => {
|
|
11628
|
+
try {
|
|
11629
|
+
const data = JSON.parse(ev.data);
|
|
11630
|
+
if (this.topicRecs.job) {
|
|
11631
|
+
this.topicRecs.job.phase = data.phase;
|
|
11632
|
+
this.topicRecs.job.label = data.label;
|
|
11633
|
+
this.topicRecs.job.detail = "";
|
|
11634
|
+
}
|
|
11635
|
+
updateStrip();
|
|
11636
|
+
} catch { /* noop */ }
|
|
11637
|
+
});
|
|
11638
|
+
es.addEventListener("topic-phase-progress", (ev) => {
|
|
11639
|
+
try {
|
|
11640
|
+
const data = JSON.parse(ev.data);
|
|
11641
|
+
if (this.topicRecs.job) {
|
|
11642
|
+
this.topicRecs.job.phase = data.phase;
|
|
11643
|
+
this.topicRecs.job.detail = data.detail || "";
|
|
11644
|
+
this.topicRecs.job.pct = data.progressPct || this.topicRecs.job.pct;
|
|
11645
|
+
}
|
|
11646
|
+
updateStrip();
|
|
11647
|
+
} catch { /* noop */ }
|
|
11648
|
+
});
|
|
11649
|
+
es.addEventListener("topic-phase-end", (ev) => {
|
|
11650
|
+
try {
|
|
11651
|
+
const data = JSON.parse(ev.data);
|
|
11652
|
+
if (this.topicRecs.job) {
|
|
11653
|
+
this.topicRecs.job.pct = data.progressPct || this.topicRecs.job.pct;
|
|
11654
|
+
}
|
|
11655
|
+
updateStrip();
|
|
11656
|
+
} catch { /* noop */ }
|
|
11657
|
+
});
|
|
11658
|
+
es.addEventListener("topic-final", () => {
|
|
11659
|
+
// Pull the freshest first page so the new cards land
|
|
11660
|
+
// immediately; closing the EventSource is on the same
|
|
11661
|
+
// tick so the next click doesn't see a stale job.
|
|
11662
|
+
terminate();
|
|
11663
|
+
void this.refreshTopicRecs();
|
|
11664
|
+
});
|
|
11665
|
+
es.addEventListener("topic-error", (ev) => {
|
|
11666
|
+
let msg = "generation failed";
|
|
11667
|
+
try { msg = (JSON.parse(ev.data).message) || msg; } catch { /* noop */ }
|
|
11668
|
+
alert("Topic generation failed: " + msg);
|
|
11669
|
+
terminate();
|
|
11670
|
+
});
|
|
11671
|
+
es.addEventListener("topic-aborted", () => {
|
|
11672
|
+
terminate();
|
|
11673
|
+
});
|
|
11674
|
+
es.onerror = () => {
|
|
11675
|
+
// Transport hiccup · treat as terminal so the UI doesn't
|
|
11676
|
+
// stay stuck on a phantom progress strip.
|
|
11677
|
+
if (this.topicRecs.job) terminate();
|
|
11678
|
+
};
|
|
11679
|
+
},
|
|
11680
|
+
|
|
11681
|
+
/** Click handler on a recommendation card · fetches the
|
|
11682
|
+
* full row (to recover seedContext) then applies it to the
|
|
11683
|
+
* composer state so the next Convene carries the snippets
|
|
11684
|
+
* through to the opening message's meta. */
|
|
11685
|
+
async applyTopicRec(id) {
|
|
11686
|
+
if (!id) return;
|
|
11687
|
+
try {
|
|
11688
|
+
const r = await fetch(`/api/topic-recs/${encodeURIComponent(id)}`);
|
|
11689
|
+
if (!r.ok) return;
|
|
11690
|
+
const row = await r.json();
|
|
11691
|
+
if (!row || typeof row.subject !== "string") return;
|
|
11692
|
+
const state = this.loadComposerState();
|
|
11693
|
+
state.subject = row.subject;
|
|
11694
|
+
state.seedContext = {
|
|
11695
|
+
topicRecId: row.id,
|
|
11696
|
+
// Rationale is the "why this fits you" line the
|
|
11697
|
+
// synthesiser produced · it's hidden from the card
|
|
11698
|
+
// UI but forwarded as background context so the
|
|
11699
|
+
// chair's clarify prompt can ground its first turn
|
|
11700
|
+
// in the same reasoning the recommendation was
|
|
11701
|
+
// built on.
|
|
11702
|
+
rationale: typeof row.rationale === "string" ? row.rationale : "",
|
|
11703
|
+
snippets: Array.isArray(row.seedContext) ? row.seedContext : [],
|
|
11704
|
+
};
|
|
11705
|
+
this.saveComposerState();
|
|
11706
|
+
this.renderEmptyState();
|
|
11707
|
+
setTimeout(() => {
|
|
11708
|
+
const ta = document.querySelector("[data-composer-subject]");
|
|
11709
|
+
if (ta) {
|
|
11710
|
+
ta.focus();
|
|
11711
|
+
ta.setSelectionRange(ta.value.length, ta.value.length);
|
|
11712
|
+
this.autosizeComposerTextarea?.();
|
|
11713
|
+
}
|
|
11714
|
+
}, 50);
|
|
11715
|
+
} catch { /* noop */ }
|
|
11716
|
+
},
|
|
11717
|
+
|
|
10590
11718
|
/** Apply a starter spec into the composer state — fills the
|
|
10591
11719
|
* textarea, swaps the cast / tone / intensity to the starter's
|
|
10592
11720
|
* presets, then re-renders. User can adjust before hitting Enter. */
|
|
@@ -11253,6 +12381,56 @@
|
|
|
11253
12381
|
this.renderRoundTable();
|
|
11254
12382
|
},
|
|
11255
12383
|
|
|
12384
|
+
/** Chair clarify bubble · parallel surface to the user bubble.
|
|
12385
|
+
* Pins the chair's clarifying question to the chair seat with a
|
|
12386
|
+
* 10s border countdown (same conic-gradient mechanic as the
|
|
12387
|
+
* user bubble). Voice-mode only · in text mode the question
|
|
12388
|
+
* appears as a normal chat bubble and the user can read it
|
|
12389
|
+
* inline. Calling again before timeout REPLACES the text and
|
|
12390
|
+
* resets the deadline. */
|
|
12391
|
+
CHAIR_BUBBLE_TTL_MS: 10000,
|
|
12392
|
+
showChairBubble(text) {
|
|
12393
|
+
const trimmed = String(text || "").trim();
|
|
12394
|
+
if (!trimmed) return;
|
|
12395
|
+
if (this.currentRoom && this.currentRoom.status === "adjourned") return;
|
|
12396
|
+
this.chairBubble.text = trimmed;
|
|
12397
|
+
this.chairBubble.deadline = Date.now() + this.CHAIR_BUBBLE_TTL_MS;
|
|
12398
|
+
this.chairBubble.dismissed = false;
|
|
12399
|
+
if (!this.chairBubble.intervalId) {
|
|
12400
|
+
this.chairBubble.intervalId = setInterval(() => this.tickChairBubble(), 1000);
|
|
12401
|
+
}
|
|
12402
|
+
this.renderRoundTable();
|
|
12403
|
+
},
|
|
12404
|
+
|
|
12405
|
+
/** 1Hz tick · surgical update of the border-progress CSS var.
|
|
12406
|
+
* Mirrors tickUserBubble exactly · see that method for the
|
|
12407
|
+
* full reasoning around surgical-vs-full rerender. */
|
|
12408
|
+
tickChairBubble() {
|
|
12409
|
+
if (this.chairBubble.dismissed) return;
|
|
12410
|
+
const now = Date.now();
|
|
12411
|
+
if (now >= this.chairBubble.deadline) {
|
|
12412
|
+
this.dismissChairBubble();
|
|
12413
|
+
return;
|
|
12414
|
+
}
|
|
12415
|
+
const bubbleEl = document.querySelector("[data-rt-chair-bubble]");
|
|
12416
|
+
if (bubbleEl) {
|
|
12417
|
+
const elapsed = this.CHAIR_BUBBLE_TTL_MS - (this.chairBubble.deadline - now);
|
|
12418
|
+
const progress = Math.min(1, Math.max(0, elapsed / this.CHAIR_BUBBLE_TTL_MS));
|
|
12419
|
+
bubbleEl.style.setProperty("--rt-bubble-chair-progress", progress.toFixed(3));
|
|
12420
|
+
}
|
|
12421
|
+
},
|
|
12422
|
+
|
|
12423
|
+
dismissChairBubble() {
|
|
12424
|
+
if (this.chairBubble.intervalId) {
|
|
12425
|
+
clearInterval(this.chairBubble.intervalId);
|
|
12426
|
+
this.chairBubble.intervalId = null;
|
|
12427
|
+
}
|
|
12428
|
+
this.chairBubble.dismissed = true;
|
|
12429
|
+
this.chairBubble.text = "";
|
|
12430
|
+
this.chairBubble.deadline = 0;
|
|
12431
|
+
this.renderRoundTable();
|
|
12432
|
+
},
|
|
12433
|
+
|
|
11256
12434
|
/** Cycle to the next preset rate · wraps around at the top.
|
|
11257
12435
|
* Persists to localStorage and applies the new rate to every
|
|
11258
12436
|
* audio element currently playing in `voiceQueues`. The HUD
|
|
@@ -13304,6 +14482,19 @@
|
|
|
13304
14482
|
`<button type="button" class="rt-bubble-user-close" data-rt-user-bubble-close aria-label="Dismiss">✕</button>` +
|
|
13305
14483
|
`</div>`;
|
|
13306
14484
|
}
|
|
14485
|
+
} else if (isChair && this.chairBubble && !this.chairBubble.dismissed
|
|
14486
|
+
&& this.chairBubble.text && Date.now() < this.chairBubble.deadline) {
|
|
14487
|
+
// Chair clarify question · pinned to the chair seat with
|
|
14488
|
+
// border countdown. Takes precedence over the "Speaking"
|
|
14489
|
+
// status bubble since the chair has already finished its
|
|
14490
|
+
// turn at this point (message-final flipped streaming off).
|
|
14491
|
+
const cb = this.chairBubble;
|
|
14492
|
+
const elapsed = this.CHAIR_BUBBLE_TTL_MS - (cb.deadline - Date.now());
|
|
14493
|
+
const progress = Math.min(1, Math.max(0, elapsed / this.CHAIR_BUBBLE_TTL_MS));
|
|
14494
|
+
bubble = `<div class="rt-bubble rt-bubble-chair-clarify" data-rt-chair-bubble style="--rt-bubble-chair-progress: ${progress.toFixed(3)}">` +
|
|
14495
|
+
`<span class="rt-bubble-chair-clarify-text">${this.escape(cb.text)}</span>` +
|
|
14496
|
+
`<button type="button" class="rt-bubble-chair-clarify-close" data-rt-chair-bubble-close aria-label="Dismiss">✕</button>` +
|
|
14497
|
+
`</div>`;
|
|
13307
14498
|
} else if (isSpeaking) {
|
|
13308
14499
|
bubble = `<div class="${bubbleCls}"><span class="rt-bubble-name">${this.escape(m.name || "")}</span><span class="rt-bubble-status">${this.escape(statusWord)}</span><span class="rt-bubble-dots" aria-hidden="true"><i></i><i></i><i></i></span></div>`;
|
|
13309
14500
|
}
|
|
@@ -13328,6 +14519,11 @@
|
|
|
13328
14519
|
: false;
|
|
13329
14520
|
const isChairBusy = (() => {
|
|
13330
14521
|
if (this.chairPending === true) return true;
|
|
14522
|
+
// Chair message landed but voice synthesis hasn't reached
|
|
14523
|
+
// the client yet · the popover would otherwise flash for
|
|
14524
|
+
// 0.5-2s before audio kicks in. See `_chairVoiceAwaiting`
|
|
14525
|
+
// doc + the message-appended hook for the full reasoning.
|
|
14526
|
+
if (this._chairVoiceAwaiting && this._chairVoiceAwaiting.size > 0) return true;
|
|
13331
14527
|
const allMsgs = this.currentMessages || [];
|
|
13332
14528
|
for (let k = allMsgs.length - 1; k >= 0; k--) {
|
|
13333
14529
|
const mm = allMsgs[k];
|
|
@@ -15111,6 +16307,13 @@
|
|
|
15111
16307
|
app.dismissUserBubble();
|
|
15112
16308
|
return;
|
|
15113
16309
|
}
|
|
16310
|
+
// Same `×` pattern on the chair clarify bubble.
|
|
16311
|
+
const chairBubbleClose = e.target.closest("[data-rt-chair-bubble-close]");
|
|
16312
|
+
if (chairBubbleClose) {
|
|
16313
|
+
e.preventDefault();
|
|
16314
|
+
app.dismissChairBubble();
|
|
16315
|
+
return;
|
|
16316
|
+
}
|
|
15114
16317
|
// Web Search · click the badge to expand / collapse the source list
|
|
15115
16318
|
// beneath the bubble.
|
|
15116
16319
|
const wsBtn = e.target.closest("[data-msg-ws-toggle]");
|
|
@@ -16096,6 +17299,23 @@
|
|
|
16096
17299
|
if (Number.isFinite(idx)) app.applyComposerStarter(idx);
|
|
16097
17300
|
return;
|
|
16098
17301
|
}
|
|
17302
|
+
// Topic-rec card → apply via /api/topic-recs/:id so the
|
|
17303
|
+
// full seedContext lands in composer state.
|
|
17304
|
+
const composerRec = e.target.closest("[data-cmp-rec]");
|
|
17305
|
+
if (composerRec) {
|
|
17306
|
+
e.preventDefault();
|
|
17307
|
+
const id = composerRec.getAttribute("data-cmp-rec");
|
|
17308
|
+
if (id) app.applyTopicRec(id);
|
|
17309
|
+
return;
|
|
17310
|
+
}
|
|
17311
|
+
// Topic-rec trigger button → start a fresh generation job.
|
|
17312
|
+
if (e.target.closest("[data-cmp-recs-trigger]")) {
|
|
17313
|
+
e.preventDefault();
|
|
17314
|
+
void app.startTopicRecJob();
|
|
17315
|
+
return;
|
|
17316
|
+
}
|
|
17317
|
+
// ("+ N more" pagination removed · tray now always shows
|
|
17318
|
+
// the latest 6 recs and wipes on each fresh generation.)
|
|
16099
17319
|
// Delete a room (any state): confirm, then real DELETE on the backend.
|
|
16100
17320
|
const del = e.target.closest("[data-room-delete]");
|
|
16101
17321
|
if (del) {
|
|
@@ -16106,6 +17326,42 @@
|
|
|
16106
17326
|
if (id) app.deleteRoom(id);
|
|
16107
17327
|
return;
|
|
16108
17328
|
}
|
|
17329
|
+
// Share a saved note · opens the share-card overlay.
|
|
17330
|
+
const noteShare = e.target.closest("[data-note-share]");
|
|
17331
|
+
if (noteShare) {
|
|
17332
|
+
e.preventDefault();
|
|
17333
|
+
e.stopPropagation();
|
|
17334
|
+
const item = noteShare.closest("[data-note-id]");
|
|
17335
|
+
const id = item?.dataset.noteId;
|
|
17336
|
+
if (id) app.openShareCard(id);
|
|
17337
|
+
return;
|
|
17338
|
+
}
|
|
17339
|
+
// Share-card overlay · template chip click swaps the visual.
|
|
17340
|
+
const shareTplBtn = e.target.closest("[data-share-card-template]");
|
|
17341
|
+
if (shareTplBtn) {
|
|
17342
|
+
e.preventDefault();
|
|
17343
|
+
const key = shareTplBtn.getAttribute("data-share-card-template");
|
|
17344
|
+
if (key) app.setShareCardTemplate(key);
|
|
17345
|
+
return;
|
|
17346
|
+
}
|
|
17347
|
+
// Share-card overlay · download.
|
|
17348
|
+
if (e.target.closest("[data-share-card-download]")) {
|
|
17349
|
+
e.preventDefault();
|
|
17350
|
+
app.downloadShareCard();
|
|
17351
|
+
return;
|
|
17352
|
+
}
|
|
17353
|
+
// Share-card overlay · close button.
|
|
17354
|
+
if (e.target.closest("[data-share-card-close]")) {
|
|
17355
|
+
e.preventDefault();
|
|
17356
|
+
app.closeShareCard();
|
|
17357
|
+
return;
|
|
17358
|
+
}
|
|
17359
|
+
// Share-card overlay · backdrop click dismisses (mirrors the
|
|
17360
|
+
// pause-choice / vote-trigger overlay convention).
|
|
17361
|
+
if (e.target.id === "share-card-overlay") {
|
|
17362
|
+
app.closeShareCard();
|
|
17363
|
+
return;
|
|
17364
|
+
}
|
|
16109
17365
|
// Delete a saved note from the All Notes list. The button is a
|
|
16110
17366
|
// sibling of the note's anchor (inside .notes-item) so its click
|
|
16111
17367
|
// never bubbles through the navigation link — but we still
|