openclacky 1.2.15 → 1.2.17

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.
@@ -0,0 +1,843 @@
1
+ // share.js — Share hooks for spreading the word at peak-delight moments.
2
+ //
3
+ // Two entry points, one shared UI:
4
+ // Share.openModal() — always-available header button
5
+ // Share.maybePromptOnComplete() — fires after a task succeeds, gated by
6
+ // a frequency cap (first + 5th success,
7
+ // then a 7-day cooldown after dismissal)
8
+ //
9
+ // Brand-aware: when this install is white-labelled, ALL share content
10
+ // (product name, landing link, QR target, logo) switches to the brand's
11
+ // values. It NEVER leaks "OpenClacky" / openclacky.com into a branded build.
12
+ //
13
+ // Depends on: qrcode (vendor/qrcode/qrcode.min.js), I18n, Modal.toast.
14
+ // ─────────────────────────────────────────────────────────────────────────
15
+ const Share = (() => {
16
+ const DEFAULT_NAME = "OpenClacky";
17
+ const DEFAULT_HOMEPAGE = "https://www.openclacky.com/";
18
+
19
+ const COUNT_KEY = "clacky-share-success-count";
20
+ const COOLDOWN_KEY = "clacky-share-cooldown-until";
21
+ const COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000;
22
+ const PROMPT_AT = [1, 5]; // success counts that trigger an auto-prompt
23
+
24
+ const THEME_KEY = "clacky-share-theme"; // remembers last picked poster style
25
+ const DRAFT_KEY = "clacky-share-draft"; // remembers last hand-edited text
26
+
27
+ // Poster themes: each is a palette the canvas renderers read from, so the
28
+ // layout stays shared and only colors change. Order = picker order.
29
+ const THEMES = {
30
+ geek: {
31
+ labelKey: "share.theme.geek", style: "geek",
32
+ bg: ["#0f172a", "#1e293b"], scoreBg: ["#0b1220", "#13243b"],
33
+ title: "#ffffff", tagline: "#f1f5f9", period: "#7dd3fc",
34
+ hero: "#38bdf8", metric: "#ffffff", metricLabel: "#cbd5e1",
35
+ golden: "#fcd34d", brand: "#e2e8f0", scan: "#94a3b8", swatch: "#1e293b",
36
+ grid: "rgba(125,211,252,0.06)",
37
+ heat: { empty: "rgba(255,255,255,0.06)", scale: ["#0e3a52", "#0a6e96", "#1aa3d6", "#38bdf8", "#7dd3fc"] },
38
+ glows: [
39
+ { x: 0.20, y: 0.18, r: 0.55, color: "rgba(56,189,248,0.22)" },
40
+ { x: 0.85, y: 0.42, r: 0.50, color: "rgba(168,85,247,0.18)" },
41
+ { x: 0.50, y: 0.92, r: 0.60, color: "rgba(14,165,233,0.14)" }
42
+ ]
43
+ },
44
+ light: {
45
+ labelKey: "share.theme.light", style: "light",
46
+ bg: ["#f8fafc", "#e2e8f0"], scoreBg: ["#ffffff", "#eef2f7"],
47
+ title: "#0f172a", tagline: "#475569", period: "#0284c7",
48
+ hero: "#0284c7", metric: "#0f172a", metricLabel: "#64748b",
49
+ golden: "#b45309", brand: "#334155", scan: "#94a3b8", swatch: "#e2e8f0",
50
+ grid: "rgba(2,132,199,0.05)",
51
+ heat: { empty: "rgba(2,132,199,0.08)", scale: ["#bae6fd", "#7dd3fc", "#38bdf8", "#0ea5e9", "#0369a1"] },
52
+ glows: [
53
+ { x: 0.18, y: 0.16, r: 0.55, color: "rgba(125,211,252,0.40)" },
54
+ { x: 0.88, y: 0.38, r: 0.50, color: "rgba(196,181,253,0.38)" },
55
+ { x: 0.55, y: 0.95, r: 0.60, color: "rgba(167,243,208,0.34)" }
56
+ ]
57
+ },
58
+ warm: {
59
+ labelKey: "share.theme.warm", style: "warm",
60
+ bg: ["#fff1eb", "#ffd9c0"], scoreBg: ["#fff5f0", "#ffe0cc"],
61
+ title: "#7c2d12", tagline: "#9a3412", period: "#ea580c",
62
+ hero: "#ea580c", metric: "#7c2d12", metricLabel: "#c2410c",
63
+ golden: "#be123c", brand: "#9a3412", scan: "#c2410c", swatch: "#ffd9c0",
64
+ grid: "rgba(234,88,12,0.05)",
65
+ heat: { empty: "rgba(234,88,12,0.08)", scale: ["#fed7aa", "#fdba74", "#fb923c", "#f97316", "#c2410c"] },
66
+ glows: [
67
+ { x: 0.16, y: 0.18, r: 0.58, color: "rgba(251,146,60,0.45)" },
68
+ { x: 0.90, y: 0.40, r: 0.52, color: "rgba(244,114,182,0.38)" },
69
+ { x: 0.52, y: 0.94, r: 0.62, color: "rgba(250,204,21,0.40)" }
70
+ ]
71
+ }
72
+ };
73
+ const THEME_ORDER = ["geek", "light", "warm"];
74
+
75
+ function _themeId() {
76
+ const saved = localStorage.getItem(THEME_KEY);
77
+ return THEMES[saved] ? saved : "geek";
78
+ }
79
+ function _theme() { return THEMES[_themeId()]; }
80
+ function _setTheme(id) { if (THEMES[id]) localStorage.setItem(THEME_KEY, id); }
81
+
82
+ // Brand info, hydrated once from /api/brand/status. Falls back to defaults
83
+ // (un-branded OpenClacky) until the fetch resolves or if it fails.
84
+ let _brand = { name: DEFAULT_NAME, homepageUrl: DEFAULT_HOMEPAGE, logoUrl: null };
85
+
86
+ // Current scorecard stats, set by openScorecard() before building the
87
+ // scorecard poster / copy. Null in plain product-share mode.
88
+ // Shape: { periods: { day|week|month: {period,cacheHitRate,costStr,...} },
89
+ // defaultPeriod, heatmap: [{date,tokens}], ...flat fallback }
90
+ let _scorecard = null;
91
+ // Currently selected scorecard period key (day | week | month).
92
+ let _scorePeriod = null;
93
+ // Set while a scorecard modal is open; lets billing hot-swap in late-arriving
94
+ // period data and re-render the period switcher.
95
+ let _rerenderPeriods = null;
96
+
97
+ // ── Brand source (single source of truth) ─────────────────────────────
98
+ async function _hydrateBrand() {
99
+ try {
100
+ const res = await fetch("/api/brand/status");
101
+ if (!res.ok) return;
102
+ const data = await res.json();
103
+ if (data && data.branded) {
104
+ _brand = {
105
+ name: (data.product_name || "").trim() || DEFAULT_NAME,
106
+ // Branded builds must NEVER fall back to openclacky.com. If the brand
107
+ // has no homepage configured, we simply show no link / QR.
108
+ homepageUrl: (data.homepage_url || "").trim() || null,
109
+ logoUrl: (data.logo_url || "").trim() || null
110
+ };
111
+ }
112
+ } catch (_e) { /* keep defaults */ }
113
+ }
114
+
115
+ // ── Share copy (i18n + brand interpolation) ───────────────────────────
116
+ // Candidate variants per platform; the UI shuffles among them and lets the
117
+ // user hand-edit before posting. Product mode shares one generic pool
118
+ // (`share.copy.*`); scorecard mode has per-platform pools with live numbers.
119
+ function _candidatesFor(platform) {
120
+ if (_scorecard) {
121
+ const list = I18n.tList("share.scorecard.copy." + platform, _scorecardVars());
122
+ return list.length ? list : [_scorecardCopy("copylink")];
123
+ }
124
+ const list = I18n.tList("share.copy", { brand: _brand.name });
125
+ return list.length ? list : [I18n.t("share.copy.1", { brand: _brand.name })];
126
+ }
127
+
128
+ function _pickCopy(platform, exclude) {
129
+ const list = _candidatesFor(platform).map((s) => s.trim());
130
+ if (list.length <= 1) return list[0] || "";
131
+ let pick = list[Math.floor(Math.random() * list.length)];
132
+ if (exclude != null) {
133
+ let guard = 0;
134
+ while (pick === exclude && guard++ < 8) {
135
+ pick = list[Math.floor(Math.random() * list.length)];
136
+ }
137
+ }
138
+ return pick;
139
+ }
140
+
141
+ function _shareUrl() {
142
+ return _brand.homepageUrl; // null when branded build has no homepage
143
+ }
144
+
145
+ // ── Scorecard copy (per-platform, numbers from stats) ─────────────────
146
+ // Returns the stats object for the currently-selected period, falling back
147
+ // to the flat fields billing.js passes for back-compat.
148
+ function _curStats() {
149
+ if (_scorecard.periods && _scorePeriod && _scorecard.periods[_scorePeriod]) {
150
+ return _scorecard.periods[_scorePeriod];
151
+ }
152
+ return _scorecard;
153
+ }
154
+
155
+ function _scorecardVars() {
156
+ const s = _curStats();
157
+ return {
158
+ brand: _brand.name,
159
+ period: s.period,
160
+ cacheHitRate: s.cacheHitRate,
161
+ cost: s.costStr,
162
+ tokens: s.tokensStr,
163
+ requests: s.requests
164
+ };
165
+ }
166
+
167
+ function _scorecardCopy(platform) {
168
+ return I18n.t("share.scorecard.copy." + platform + ".1", _scorecardVars()).trim();
169
+ }
170
+
171
+ // Pick a dynamic golden line based on how strong the numbers are.
172
+ function _scorecardGoldenLine() {
173
+ const rate = parseFloat(_curStats().cacheHitRate) || 0;
174
+ const key = rate >= 90 ? "high" : rate >= 60 ? "mid" : "low";
175
+ return I18n.t("share.scorecard.golden." + key, _scorecardVars());
176
+ }
177
+
178
+ // ── Frequency cap (C2: first + 5th, 7-day cooldown after dismissal) ────
179
+ function _successCount() {
180
+ return parseInt(localStorage.getItem(COUNT_KEY) || "0", 10) || 0;
181
+ }
182
+
183
+ function _bumpSuccessCount() {
184
+ const n = _successCount() + 1;
185
+ localStorage.setItem(COUNT_KEY, String(n));
186
+ return n;
187
+ }
188
+
189
+ function _inCooldown() {
190
+ const until = parseInt(localStorage.getItem(COOLDOWN_KEY) || "0", 10) || 0;
191
+ return Date.now() < until;
192
+ }
193
+
194
+ function _startCooldown() {
195
+ localStorage.setItem(COOLDOWN_KEY, String(Date.now() + COOLDOWN_MS));
196
+ }
197
+
198
+ // ── QR code (reuses qrcode-generator) ─────────────────────────────────
199
+ // Draws the QR for `url` onto a canvas 2d context at (x, y) sized `sizePx`.
200
+ function _drawQrToCanvas(ctx, url, x, y, sizePx) {
201
+ const qr = qrcode(0, "M");
202
+ qr.addData(url);
203
+ qr.make();
204
+ const count = qr.getModuleCount();
205
+ const quiet = 2;
206
+ const total = count + quiet * 2;
207
+ const module = sizePx / total;
208
+
209
+ ctx.fillStyle = "#ffffff";
210
+ ctx.fillRect(x, y, sizePx, sizePx);
211
+ ctx.fillStyle = "#1a1a1a";
212
+ for (let r = 0; r < count; r++) {
213
+ for (let c = 0; c < count; c++) {
214
+ if (qr.isDark(r, c)) {
215
+ ctx.fillRect(
216
+ x + (c + quiet) * module,
217
+ y + (r + quiet) * module,
218
+ Math.ceil(module),
219
+ Math.ceil(module)
220
+ );
221
+ }
222
+ }
223
+ }
224
+ }
225
+
226
+ // ── Poster (pure-frontend Canvas, zero token, instant) ────────────────
227
+ // Returns a data URL (PNG). `copy` is the live editor text, drawn as the
228
+ // poster's main line so the poster matches exactly what the user will post.
229
+ function _buildPoster(copy) {
230
+ const W = 720, H = 1080;
231
+ const t = _theme();
232
+ const canvas = document.createElement("canvas");
233
+ canvas.width = W;
234
+ canvas.height = H;
235
+ const ctx = canvas.getContext("2d");
236
+
237
+ // Background.
238
+ _paintBackground(ctx, W, H, t, t.bg);
239
+
240
+ // Brand name.
241
+ ctx.fillStyle = t.title;
242
+ ctx.textAlign = "center";
243
+ ctx.font = "700 60px -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif";
244
+ ctx.fillText(_brand.name, W / 2, 150);
245
+
246
+ // Main line = the live editor text (so poster === shared text).
247
+ const text = (copy || "").trim() || I18n.t("share.poster.tagline", { brand: _brand.name });
248
+ ctx.fillStyle = t.tagline;
249
+ _drawAutoText(ctx, text, W / 2, 250, W - 120, 480 - 250);
250
+
251
+ // QR code (only when there's a URL to point at).
252
+ const url = _shareUrl();
253
+ if (url) {
254
+ const qrSize = 300;
255
+ const qrX = (W - qrSize) / 2;
256
+ const qrY = 560;
257
+ ctx.fillStyle = "#ffffff";
258
+ _roundRect(ctx, qrX - 24, qrY - 24, qrSize + 48, qrSize + 48, 22);
259
+ ctx.fill();
260
+ _drawQrToCanvas(ctx, url, qrX, qrY, qrSize);
261
+
262
+ ctx.fillStyle = t.scan;
263
+ ctx.font = "400 28px -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif";
264
+ ctx.fillText(I18n.t("share.poster.scan"), W / 2, qrY + qrSize + 80);
265
+
266
+ ctx.fillStyle = t.scan;
267
+ ctx.font = "400 24px -apple-system, sans-serif";
268
+ ctx.fillText(url.replace(/^https?:\/\//, "").replace(/\/$/, ""), W / 2, H - 60);
269
+ }
270
+
271
+ return canvas.toDataURL("image/png");
272
+ }
273
+
274
+ // ── Scorecard poster (B-line: spend / cache-hit bragging card) ────────
275
+ // Hero = the cache-hit rate (the delight number). Brand is a footer chip.
276
+ function _buildScorecardPoster(copy) {
277
+ const W = 720, H = 1080;
278
+ const t = _theme();
279
+ const canvas = document.createElement("canvas");
280
+ canvas.width = W;
281
+ canvas.height = H;
282
+ const ctx = canvas.getContext("2d");
283
+
284
+ _paintBackground(ctx, W, H, t, t.scoreBg);
285
+
286
+ ctx.textAlign = "center";
287
+
288
+ // Title + period.
289
+ ctx.fillStyle = t.title;
290
+ ctx.font = "700 48px -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif";
291
+ ctx.fillText(I18n.t("share.scorecard.poster.title"), W / 2, 110);
292
+
293
+ const s = _curStats();
294
+ const showHeat = _scorePeriod === "month" && _scorecard.heatmap && _scorecard.heatmap.length > 0;
295
+
296
+ ctx.fillStyle = t.period;
297
+ ctx.font = "400 28px -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif";
298
+ ctx.fillText(s.period, W / 2, 162);
299
+
300
+ // Hero: cache hit rate.
301
+ ctx.fillStyle = t.hero;
302
+ ctx.font = "800 150px -apple-system, 'PingFang SC', sans-serif";
303
+ ctx.fillText(s.cacheHitRate + "%", W / 2, 348);
304
+
305
+ ctx.fillStyle = t.tagline;
306
+ ctx.font = "400 32px -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif";
307
+ ctx.fillText(I18n.t("share.scorecard.poster.cacheLabel"), W / 2, 408);
308
+
309
+ // Secondary metrics: cost + tokens.
310
+ ctx.fillStyle = t.metric;
311
+ ctx.font = "700 48px -apple-system, 'PingFang SC', sans-serif";
312
+ ctx.fillText(s.costStr, W / 2 - 160, 494);
313
+ ctx.fillText(s.tokensStr, W / 2 + 160, 494);
314
+ ctx.fillStyle = t.metricLabel;
315
+ ctx.font = "400 26px -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif";
316
+ ctx.fillText(I18n.t("share.scorecard.poster.costLabel"), W / 2 - 160, 534);
317
+ ctx.fillText(I18n.t("share.scorecard.poster.tokensLabel"), W / 2 + 160, 534);
318
+
319
+ // Monthly heatmap (calendar style). Pushes everything below down.
320
+ let cursorY = 574;
321
+ if (showHeat) {
322
+ cursorY = _drawHeatmap(ctx, W, 552, t);
323
+ }
324
+
325
+ // Golden line = live editor text (poster matches what gets posted).
326
+ const goldenTop = showHeat ? cursorY + 14 : 614;
327
+ const goldenH = showHeat ? 78 : 110;
328
+ const line = (copy || "").trim() || _scorecardGoldenLine();
329
+ ctx.fillStyle = t.golden;
330
+ _drawAutoText(ctx, line, W / 2, goldenTop, W - 120, goldenH);
331
+
332
+ // QR + brand chip (gated: branded builds with no homepage show neither).
333
+ const url = _shareUrl();
334
+ const qrSize = showHeat ? 152 : 230;
335
+ const qrY = goldenTop + goldenH + (showHeat ? 14 : 30);
336
+ if (url) {
337
+ const qrX = (W - qrSize) / 2;
338
+ ctx.fillStyle = "#ffffff";
339
+ _roundRect(ctx, qrX - 16, qrY - 16, qrSize + 32, qrSize + 32, 16);
340
+ ctx.fill();
341
+ _drawQrToCanvas(ctx, url, qrX, qrY, qrSize);
342
+
343
+ ctx.fillStyle = t.brand;
344
+ ctx.font = "600 28px -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif";
345
+ ctx.fillText(_brand.name, W / 2, qrY + qrSize + (showHeat ? 40 : 52));
346
+ if (!showHeat) {
347
+ ctx.fillStyle = t.scan;
348
+ ctx.font = "400 23px -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif";
349
+ ctx.fillText(I18n.t("share.scorecard.poster.scan"), W / 2, qrY + qrSize + 86);
350
+ }
351
+ } else {
352
+ ctx.fillStyle = t.brand;
353
+ ctx.font = "600 34px -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif";
354
+ ctx.fillText(_brand.name, W / 2, qrY + 40);
355
+ }
356
+
357
+ return canvas.toDataURL("image/png");
358
+ }
359
+
360
+ // Draws a GitHub-contribution-style heatmap of daily token usage. Cells are
361
+ // laid out in columns of 7 (one week per column), oldest → newest. Color
362
+ // Draws a calendar-style heatmap of daily token usage: 7 columns (Sun→Sat),
363
+ // one week per row, aligned to real weekdays. Color ramps with usage via the
364
+ // theme's `heat` scale. Returns the Y just below it.
365
+ function _drawHeatmap(ctx, W, top, t) {
366
+ const days = _scorecard.heatmap;
367
+ const heat = t.heat || { empty: "rgba(255,255,255,0.08)", scale: ["#9be9a8", "#40c463", "#30a14e", "#216e39", "#0a4020"] };
368
+ const cols = 7;
369
+ const gap = 6;
370
+ const cell = 30;
371
+ const gridW = cols * cell + (cols - 1) * gap;
372
+ const x0 = (W - gridW) / 2;
373
+
374
+ // Title above the grid.
375
+ ctx.textAlign = "center";
376
+ ctx.fillStyle = t.metricLabel;
377
+ ctx.font = "400 24px -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif";
378
+ ctx.fillText(I18n.t("share.scorecard.poster.heatmapLabel"), W / 2, top);
379
+
380
+ const gridTop = top + 18;
381
+ const maxTok = Math.max.apply(null, days.map((d) => d.tokens).concat([1]));
382
+
383
+ // Weekday offset of the first day so columns line up with real weekdays.
384
+ const firstDow = days.length ? new Date(days[0].date + "T00:00:00").getDay() : 0;
385
+ let maxRow = 0;
386
+
387
+ days.forEach((d, i) => {
388
+ const slot = firstDow + i;
389
+ const col = slot % cols;
390
+ const row = Math.floor(slot / cols);
391
+ if (row > maxRow) maxRow = row;
392
+ const x = x0 + col * (cell + gap);
393
+ const y = gridTop + row * (cell + gap);
394
+ let color = heat.empty;
395
+ if (d.tokens > 0) {
396
+ const ratio = d.tokens / maxTok;
397
+ const idx = ratio >= 0.75 ? 4 : ratio >= 0.5 ? 3 : ratio >= 0.25 ? 2 : ratio >= 0.08 ? 1 : 0;
398
+ color = heat.scale[idx];
399
+ }
400
+ ctx.fillStyle = color;
401
+ _roundRect(ctx, x, y, cell, cell, 6);
402
+ ctx.fill();
403
+ });
404
+
405
+ return gridTop + (maxRow + 1) * (cell + gap) - gap;
406
+ }
407
+
408
+ // Draws `text` centered at cx, fitting within (maxWidth × maxHeight) starting
409
+ // at startY. Picks the largest font (from a descending ladder) whose wrapped
410
+ // lines fit the height, honoring explicit "\n" breaks. Vertically centers.
411
+ function _drawAutoText(ctx, text, cx, startY, maxWidth, maxHeight) {
412
+ const sizes = [40, 36, 32, 28, 24, 20];
413
+ let chosen = null;
414
+ for (const size of sizes) {
415
+ ctx.font = "500 " + size + "px -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif";
416
+ const lh = Math.round(size * 1.4);
417
+ const lines = _wrapLines(ctx, text, maxWidth);
418
+ if (lines.length * lh <= maxHeight || size === sizes[sizes.length - 1]) {
419
+ chosen = { size, lh, lines };
420
+ break;
421
+ }
422
+ }
423
+ ctx.font = "500 " + chosen.size + "px -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif";
424
+ const totalH = chosen.lines.length * chosen.lh;
425
+ let y = startY + (maxHeight - totalH) / 2 + chosen.lh * 0.75;
426
+ for (const line of chosen.lines) {
427
+ ctx.fillText(line, cx, y);
428
+ y += chosen.lh;
429
+ }
430
+ }
431
+
432
+ // Wraps text into lines fitting maxWidth, honoring explicit "\n" breaks.
433
+ function _wrapLines(ctx, text, maxWidth) {
434
+ const out = [];
435
+ for (const para of String(text).split("\n")) {
436
+ let line = "";
437
+ for (const ch of para) {
438
+ if (ctx.measureText(line + ch).width > maxWidth && line) {
439
+ out.push(line);
440
+ line = ch;
441
+ } else {
442
+ line += ch;
443
+ }
444
+ }
445
+ out.push(line);
446
+ }
447
+ return out;
448
+ }
449
+
450
+ function _roundRect(ctx, x, y, w, h, r) {
451
+ ctx.beginPath();
452
+ ctx.moveTo(x + r, y);
453
+ ctx.arcTo(x + w, y, x + w, y + h, r);
454
+ ctx.arcTo(x + w, y + h, x, y + h, r);
455
+ ctx.arcTo(x, y + h, x, y, r);
456
+ ctx.arcTo(x, y, x + w, y, r);
457
+ ctx.closePath();
458
+ }
459
+
460
+ // Paints a richer-than-flat poster background: base diagonal gradient, soft
461
+ // radial color glows, and a faint grid. Deterministic (no randomness) so the
462
+ // poster is reproducible and white-label safe.
463
+ function _paintBackground(ctx, W, H, t, base) {
464
+ const baseGrad = ctx.createLinearGradient(0, 0, W, H);
465
+ baseGrad.addColorStop(0, base[0]);
466
+ baseGrad.addColorStop(1, base[1]);
467
+ ctx.fillStyle = baseGrad;
468
+ ctx.fillRect(0, 0, W, H);
469
+
470
+ const diag = Math.sqrt(W * W + H * H);
471
+ (t.glows || []).forEach((g) => {
472
+ const cx = g.x * W, cy = g.y * H, radius = g.r * diag * 0.5;
473
+ const rg = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);
474
+ rg.addColorStop(0, g.color);
475
+ rg.addColorStop(1, "rgba(0,0,0,0)");
476
+ ctx.fillStyle = rg;
477
+ ctx.fillRect(0, 0, W, H);
478
+ });
479
+
480
+ if (t.grid) {
481
+ ctx.strokeStyle = t.grid;
482
+ ctx.lineWidth = 1;
483
+ const step = 48;
484
+ ctx.beginPath();
485
+ for (let x = step; x < W; x += step) { ctx.moveTo(x, 0); ctx.lineTo(x, H); }
486
+ for (let y = step; y < H; y += step) { ctx.moveTo(0, y); ctx.lineTo(W, y); }
487
+ ctx.stroke();
488
+ }
489
+
490
+ // Subtle top sheen for depth.
491
+ const sheen = ctx.createLinearGradient(0, 0, 0, H * 0.35);
492
+ sheen.addColorStop(0, t.style === "geek" ? "rgba(255,255,255,0.06)" : "rgba(255,255,255,0.18)");
493
+ sheen.addColorStop(1, "rgba(255,255,255,0)");
494
+ ctx.fillStyle = sheen;
495
+ ctx.fillRect(0, 0, W, H * 0.35);
496
+ }
497
+
498
+ // ── Platform actions ──────────────────────────────────────────────────
499
+ function _copy(str) {
500
+ if (navigator.clipboard && navigator.clipboard.writeText) {
501
+ navigator.clipboard.writeText(str).then(
502
+ () => Modal.toast(I18n.t("share.copied"), "success"),
503
+ () => _copyFallback(str)
504
+ );
505
+ } else {
506
+ _copyFallback(str);
507
+ }
508
+ }
509
+
510
+ function _copyFallback(str) {
511
+ const ta = document.createElement("textarea");
512
+ ta.value = str;
513
+ ta.style.position = "fixed";
514
+ ta.style.opacity = "0";
515
+ document.body.appendChild(ta);
516
+ ta.select();
517
+ try { document.execCommand("copy"); Modal.toast(I18n.t("share.copied"), "success"); }
518
+ catch (_e) { Modal.toast(I18n.t("share.copyFailed"), "error"); }
519
+ finally { ta.remove(); }
520
+ }
521
+
522
+ // Append the landing URL (when present) to a body of share text.
523
+ function _withUrl(body) {
524
+ const url = _shareUrl();
525
+ return url ? `${body} ${url}`.trim() : body;
526
+ }
527
+
528
+ function _toWeibo(text) {
529
+ const url = _shareUrl() || "";
530
+ const share = "https://service.weibo.com/share/share.php?url=" +
531
+ encodeURIComponent(url) + "&title=" + encodeURIComponent(text);
532
+ window.open(share, "_blank", "noopener,noreferrer");
533
+ }
534
+
535
+ // ── Poster sharing helpers ────────────────────────────────────────────
536
+ function _posterFilename() {
537
+ return `${_brand.name.toLowerCase()}-${_scorecard ? "scorecard" : "share"}.png`;
538
+ }
539
+
540
+ function _downloadPoster(copy) {
541
+ const a = document.createElement("a");
542
+ a.href = _scorecard ? _buildScorecardPoster(copy) : _buildPoster(copy);
543
+ a.download = _posterFilename();
544
+ a.click();
545
+ }
546
+
547
+ function _saveDraft(text) {
548
+ try { localStorage.setItem(DRAFT_KEY, text); } catch (_e) { /* quota, ignore */ }
549
+ }
550
+ function _loadDraft() {
551
+ try { return localStorage.getItem(DRAFT_KEY) || ""; } catch (_e) { return ""; }
552
+ }
553
+
554
+ // ── Modal UI ──────────────────────────────────────────────────────────
555
+ let _overlay = null;
556
+ let _activePlatform = "weibo";
557
+
558
+ const PLATFORMS = ["weibo", "xhs", "wechat", "bilibili"];
559
+
560
+ function _telemetry(event, extra) {
561
+ if (typeof fetch === "undefined") return;
562
+ const body = JSON.stringify({ event: event, extra: extra || {} });
563
+ fetch("/api/telemetry", { method: "POST", headers: { "Content-Type": "application/json" }, body: body }).catch(() => {});
564
+ }
565
+
566
+ function openModal() {
567
+ // Tear down a stale overlay without clearing scorecard state (closeModal
568
+ // nulls _scorecard, which openScorecard has just set).
569
+ if (_overlay) { _overlay.remove(); _overlay = null; }
570
+
571
+ const hasUrl = !!_shareUrl();
572
+ const titleKey = _scorecard ? "share.scorecard.modal.title" : "share.modal.title";
573
+ const subtitleKey = _scorecard ? "share.scorecard.modal.subtitle" : "share.modal.subtitle";
574
+
575
+ _activePlatform = "weibo";
576
+
577
+ const o = document.createElement("div");
578
+ o.className = "share-overlay";
579
+
580
+ const themeChips = THEME_ORDER.map((id) => {
581
+ const th = THEMES[id];
582
+ const on = id === _themeId() ? " is-active" : "";
583
+ return '<button type="button" class="share-theme-chip' + on + '" data-theme="' + id + '"' +
584
+ ' style="background:' + th.swatch + '" title="' + _esc(I18n.t(th.labelKey)) + '">' +
585
+ '<span class="share-theme-name">' + _esc(I18n.t(th.labelKey)) + '</span></button>';
586
+ }).join("");
587
+
588
+ const platformTabs = PLATFORMS.map((p) => {
589
+ const on = p === _activePlatform ? " is-active" : "";
590
+ return '<button type="button" class="share-platform' + on + '" data-platform="' + p + '">' +
591
+ _esc(I18n.t("share.platform." + p)) + '</button>';
592
+ }).join("");
593
+
594
+ // Scorecard period switcher slot (filled by renderPeriodTabs below; can be
595
+ // re-rendered when billing hot-swaps in more periods asynchronously).
596
+
597
+ o.innerHTML =
598
+ '<div class="share-modal" role="dialog" aria-modal="true">' +
599
+ '<button type="button" class="share-close" aria-label="Close">&times;</button>' +
600
+ '<h3 class="share-title">' + _esc(I18n.t(titleKey, { brand: _brand.name })) + '</h3>' +
601
+ '<p class="share-subtitle">' + _esc(I18n.t(subtitleKey)) + '</p>' +
602
+ '<div class="share-body">' +
603
+ '<div class="share-poster-wrap"><img class="share-poster-img" alt="poster"/></div>' +
604
+ '<div class="share-controls">' +
605
+ '<div class="share-theme-row">' +
606
+ '<span class="share-row-label">' + _esc(I18n.t("share.theme.label")) + '</span>' +
607
+ '<div class="share-theme-chips">' + themeChips + '</div>' +
608
+ '</div>' +
609
+ '<div class="share-periods-slot"></div>' +
610
+ '<div class="share-platforms">' + platformTabs + '</div>' +
611
+ '<div class="share-editor">' +
612
+ '<div class="share-editor-head">' +
613
+ '<span class="share-row-label">' + _esc(I18n.t("share.editor.label")) + '</span>' +
614
+ '<button type="button" class="share-shuffle" data-act="shuffle">🎲 ' + _esc(I18n.t("share.action.shuffle")) + '</button>' +
615
+ '</div>' +
616
+ '<textarea class="share-text" rows="4"></textarea>' +
617
+ '</div>' +
618
+ '<div class="share-actions">' +
619
+ '<button type="button" class="share-btn-primary" data-act="primary"></button>' +
620
+ '<button type="button" class="share-btn-secondary" data-act="copytext">' + _esc(I18n.t("share.action.copyText")) + '</button>' +
621
+ '<button type="button" class="share-btn-secondary" data-act="download">' + _esc(I18n.t("share.action.download")) + '</button>' +
622
+ (hasUrl ? '<button type="button" class="share-btn-secondary" data-act="copylink">' + _esc(I18n.t("share.action.copyLink")) + '</button>' : '') +
623
+ '</div>' +
624
+ '</div>' +
625
+ '</div>' +
626
+ '</div>';
627
+
628
+ document.body.appendChild(o);
629
+ _overlay = o;
630
+
631
+ const img = o.querySelector(".share-poster-img");
632
+ const textarea = o.querySelector(".share-text");
633
+
634
+ const renderPoster = () => {
635
+ const copy = (textarea.value || "").trim();
636
+ try { img.src = _scorecard ? _buildScorecardPoster(copy) : _buildPoster(copy); }
637
+ catch (_e) { o.querySelector(".share-poster-wrap").style.display = "none"; }
638
+ };
639
+
640
+ // Seed the editor: prefer the user's last hand-edited draft (product mode
641
+ // only — scorecard text carries live numbers and shouldn't be stale).
642
+ const draft = _scorecard ? "" : _loadDraft();
643
+ textarea.value = draft || _pickCopy(_activePlatform);
644
+ renderPoster();
645
+
646
+ textarea.addEventListener("input", () => {
647
+ if (!_scorecard) _saveDraft(textarea.value);
648
+ renderPoster();
649
+ });
650
+
651
+ const setActivePlatform = (p) => {
652
+ _activePlatform = p;
653
+ o.querySelectorAll(".share-platform").forEach((b) => {
654
+ b.classList.toggle("is-active", b.getAttribute("data-platform") === p);
655
+ });
656
+ textarea.value = _pickCopy(p);
657
+ if (!_scorecard) _saveDraft(textarea.value);
658
+ _updatePrimaryLabel(o);
659
+ renderPoster();
660
+ };
661
+
662
+ o.querySelectorAll(".share-platform").forEach((b) => {
663
+ b.onclick = () => setActivePlatform(b.getAttribute("data-platform"));
664
+ });
665
+
666
+ o.querySelectorAll(".share-theme-chip").forEach((chip) => {
667
+ chip.onclick = () => {
668
+ _setTheme(chip.getAttribute("data-theme"));
669
+ o.querySelectorAll(".share-theme-chip").forEach((c) => {
670
+ c.classList.toggle("is-active", c === chip);
671
+ });
672
+ renderPoster();
673
+ };
674
+ });
675
+
676
+ const renderPeriodTabs = () => {
677
+ const slot = o.querySelector(".share-periods-slot");
678
+ if (!slot) return;
679
+ const order = ["day", "week", "month"];
680
+ const avail = _scorecard && _scorecard.periods ? _scorecard.periods : {};
681
+ const keys = order.filter((p) => avail[p]);
682
+ if (keys.length <= 1) { slot.innerHTML = ""; return; }
683
+ slot.innerHTML = '<div class="share-periods">' + keys.map((p) => {
684
+ const on = p === _scorePeriod ? " is-active" : "";
685
+ return '<button type="button" class="share-period' + on + '" data-period="' + p + '">' +
686
+ _esc(I18n.t("share.scorecard.period." + p)) + '</button>';
687
+ }).join("") + '</div>';
688
+ slot.querySelectorAll(".share-period").forEach((b) => {
689
+ b.onclick = () => {
690
+ _scorePeriod = b.getAttribute("data-period");
691
+ slot.querySelectorAll(".share-period").forEach((x) => {
692
+ x.classList.toggle("is-active", x === b);
693
+ });
694
+ textarea.value = _pickCopy(_activePlatform);
695
+ renderPoster();
696
+ };
697
+ });
698
+ };
699
+ renderPeriodTabs();
700
+ _rerenderPeriods = renderPeriodTabs;
701
+
702
+ const close = () => closeModal();
703
+ o.querySelector(".share-close").onclick = close;
704
+ o.addEventListener("click", (e) => { if (e.target === o) close(); });
705
+
706
+ o.querySelectorAll("[data-act]").forEach((btn) => {
707
+ const act = btn.getAttribute("data-act");
708
+ if (act === "shuffle") {
709
+ btn.onclick = () => {
710
+ textarea.value = _pickCopy(_activePlatform, textarea.value.trim());
711
+ if (!_scorecard) _saveDraft(textarea.value);
712
+ renderPoster();
713
+ };
714
+ } else {
715
+ btn.onclick = () => _handleAction(act, textarea);
716
+ }
717
+ });
718
+
719
+ _updatePrimaryLabel(o);
720
+ _telemetry("share_open", { type: _scorecard ? "scorecard" : "share" });
721
+ requestAnimationFrame(() => o.classList.add("open"));
722
+ }
723
+
724
+ // Primary button label/behavior depends on the active platform: Weibo gets
725
+ // a real one-click jump; image-first platforms (xhs/wechat/bilibili) get
726
+ // "download poster + copy text".
727
+ function _updatePrimaryLabel(o) {
728
+ const btn = o.querySelector('[data-act="primary"]');
729
+ if (!btn) return;
730
+ btn.textContent = _activePlatform === "weibo"
731
+ ? I18n.t("share.action.toWeibo")
732
+ : I18n.t("share.action.downloadAndCopy");
733
+ }
734
+
735
+ function _handleAction(act, textarea) {
736
+ const text = (textarea && textarea.value || "").trim();
737
+ switch (act) {
738
+ case "copytext":
739
+ _copy(_withUrl(text));
740
+ break;
741
+ case "download":
742
+ _downloadPoster(text);
743
+ _telemetry("share_download", { platform: _activePlatform, type: _scorecard ? "scorecard" : "share" });
744
+ break;
745
+ case "copylink":
746
+ _copy(_shareUrl());
747
+ break;
748
+ case "primary":
749
+ _primaryShare(text);
750
+ break;
751
+ }
752
+ }
753
+
754
+ function _primaryShare(text) {
755
+ switch (_activePlatform) {
756
+ case "weibo":
757
+ _toWeibo(text);
758
+ break;
759
+ default:
760
+ _downloadPoster(text);
761
+ _copy(_withUrl(text));
762
+ _telemetry("share_download", { platform: _activePlatform, type: _scorecard ? "scorecard" : "share" });
763
+ Modal.toast(I18n.t("share.hint." + _activePlatform), "info");
764
+ break;
765
+ }
766
+ }
767
+
768
+ function closeModal() {
769
+ if (!_overlay) return;
770
+ const o = _overlay;
771
+ _overlay = null;
772
+ _scorecard = null;
773
+ _scorePeriod = null;
774
+ _rerenderPeriods = null;
775
+ o.classList.remove("open");
776
+ setTimeout(() => o.remove(), 200);
777
+ }
778
+
779
+ // ── Scorecard entry (B-line) ──────────────────────────────────────────
780
+ // Opens the share modal in "scorecard" mode with live billing numbers.
781
+ // stats: { period, cacheHitRate, costStr, tokensStr, requests }
782
+ function openScorecard(stats) {
783
+ _scorecard = stats || null;
784
+ _scorePeriod = _scorecard
785
+ ? (_scorecard.defaultPeriod && _scorecard.periods && _scorecard.periods[_scorecard.defaultPeriod]
786
+ ? _scorecard.defaultPeriod
787
+ : (_scorecard.periods ? Object.keys(_scorecard.periods)[0] : null))
788
+ : null;
789
+ openModal();
790
+ }
791
+
792
+ // Called by billing when a period's numbers arrive after the modal already
793
+ // opened (instant-open path). Merges them and re-renders the switcher.
794
+ function addScorecardPeriod(key, stats) {
795
+ if (!_scorecard || !_overlay) return;
796
+ if (!_scorecard.periods) _scorecard.periods = {};
797
+ _scorecard.periods[key] = stats;
798
+ if (_rerenderPeriods) _rerenderPeriods();
799
+ }
800
+
801
+ // ── Auto-prompt at peak-delight moments ───────────────────────────────
802
+ // Called from ws-dispatcher on a successful `complete` event (the active
803
+ // session the user is watching — onboarding runs in its own panel, never
804
+ // emitting this for the chat view). Shows a gentle toast, not a modal,
805
+ // so we never interrupt the moment.
806
+ function maybePromptOnComplete() {
807
+ const n = _bumpSuccessCount();
808
+ if (_inCooldown()) return;
809
+ if (!PROMPT_AT.includes(n)) return;
810
+
811
+ Modal.toast(I18n.t("share.prompt.message", { brand: _brand.name }), "success", {
812
+ duration: 8000,
813
+ action: {
814
+ label: I18n.t("share.prompt.action"),
815
+ onClick: openModal
816
+ }
817
+ });
818
+ // Any prompt (taken or ignored) starts the cooldown so we stay quiet
819
+ // for the next 7 days regardless.
820
+ _startCooldown();
821
+ }
822
+
823
+ function _esc(s) {
824
+ return String(s ?? "")
825
+ .replace(/&/g, "&amp;").replace(/</g, "&lt;")
826
+ .replace(/>/g, "&gt;").replace(/"/g, "&quot;");
827
+ }
828
+
829
+ // ── Init ──────────────────────────────────────────────────────────────
830
+ function init() {
831
+ _hydrateBrand();
832
+ const btn = document.getElementById("share-toggle-header");
833
+ if (btn) btn.addEventListener("click", openModal);
834
+ }
835
+
836
+ return { init, openModal, openScorecard, addScorecardPeriod, closeModal, maybePromptOnComplete };
837
+ })();
838
+
839
+ if (document.readyState === "loading") {
840
+ document.addEventListener("DOMContentLoaded", () => Share.init());
841
+ } else {
842
+ Share.init();
843
+ }