@lolannn/dsh-effort-slider 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +19 -0
  2. package/client.js +2589 -2244
  3. package/package.json +60 -60
package/client.js CHANGED
@@ -1,2244 +1,2589 @@
1
- /**
2
- * dsh-effort-slider — browser half.
3
- *
4
- * Two registrations:
5
- * conversation.input.right : a compact pill that shows the session's current
6
- * reasoning effort and opens the panel.
7
- * shell.overlay : the frame-wide panel, with a starfield, a live
8
- * rail whose particles/ribbons/stars/galaxy scale
9
- * with the chosen level, and light-blade bursts.
10
- *
11
- * The levels are read at runtime from the session model catalogue, so the
12
- * slider always matches the efforts the current model actually declares.
13
- */
14
- window.__ModuleLoader__.load({
15
- id: "@lolannn/dsh-effort-slider",
16
- factory: (require) => {
17
- var module = { exports: {} };
18
- var exports = module.exports;
19
- Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
20
-
21
- const react = require("react");
22
- const reactDom = require("react-dom");
23
- const h = react.createElement;
24
-
25
- const NS = "effort-slider";
26
- const CSS_TAG = "dsh-effort-slider/client";
27
- const EDGE = 31;
28
- const SELECT_TIMEOUT_MS = 15000;
29
- const COMMIT_DEBOUNCE_MS = 260;
30
-
31
- /* ------------------------------------------------------------------ *
32
- * dictionaries
33
- * ------------------------------------------------------------------ */
34
-
35
- const zh = {
36
- pill: "思考强度",
37
- pillHint: "模型与思考强度",
38
- panelTitle: "思考强度",
39
- modelLabel: "模型",
40
- effortLabel: "思考强度",
41
- noModels: "没有可用的模型",
42
- triggerHint: "选择模型与思考强度",
43
- hint: "拖动滑块,或用 ← / → / Home / End 切换档位",
44
- reset: "回到默认档位",
45
- close: "关闭",
46
- switching: "正在切换…",
47
- failed: "切换失败:{message}",
48
- noSession: "当前没有活动会话",
49
- noReasoning: "当前模型没有可调的思考强度档位",
50
- clamped: "该模型没有「{from}」档,已自动切到最接近的「{to}」",
51
- noEffortModel: "「{model}」没有思考强度档位,已按模型默认运行",
52
- noLevelsTag: "无档位",
53
- boom: "宇宙毁灭",
54
- afterBoom: "星际探索"
55
- };
56
-
57
- const en = {
58
- pill: "Effort",
59
- pillHint: "Model and reasoning effort",
60
- panelTitle: "Reasoning effort",
61
- modelLabel: "Model",
62
- effortLabel: "Reasoning effort",
63
- noModels: "No model is available",
64
- triggerHint: "Choose model and reasoning effort",
65
- hint: "Drag the rail, or press ← / → / Home / End",
66
- reset: "Back to the default level",
67
- close: "Close",
68
- switching: "Switching…",
69
- failed: "Switch failed: {message}",
70
- noSession: "No active session",
71
- noReasoning: "This model exposes no reasoning-effort levels",
72
- clamped: "This model has no \"{from}\" level — switched to the nearest one, \"{to}\"",
73
- noEffortModel: "\"{model}\" exposes no effort levels — running at its default",
74
- noLevelsTag: "no levels",
75
- boom: "SINGULARITY",
76
- afterBoom: "DEEP SPACE"
77
- };
78
-
79
- /* ------------------------------------------------------------------ *
80
- * stylesheet
81
- * ------------------------------------------------------------------ */
82
-
83
- const CSS = [
84
- ".es-anchor{display:inline-flex;align-items:center;min-width:0;max-width:100%}",
85
- ".es-trigger{--es-hue:214;--es-e:0;position:relative;display:inline-flex;align-items:center;gap:6px;height:28px;max-width:min(340px,44vw);padding:0 9px;margin:0;border-radius:999px;border:1px solid transparent;background:transparent;color:#c3cbdb;font:inherit;font-size:12.5px;font-weight:500;line-height:1;cursor:pointer;white-space:nowrap;transition:background .2s ease,border-color .25s ease,color .2s ease,box-shadow .35s ease}",
86
- ".es-trigger:hover{background:rgba(255,255,255,.055);border-color:rgba(255,255,255,.1);color:#eef2fb}",
87
- '.es-trigger[data-open="1"]{background:rgba(255,255,255,.08);border-color:hsl(var(--es-hue) 100% 74% / .45);color:#fff;box-shadow:0 0 14px hsl(var(--es-hue) 100% 66% / .28)}',
88
- '.es-trigger[data-locked="1"]{opacity:.5;cursor:default}',
89
- ".es-trigger-bolt{display:var(--dsh-composer-model-icon-display,none);width:14px;height:14px;flex:none;align-items:center;justify-content:center;color:hsl(var(--es-hue) 100% 78%)}",
90
- ".es-trigger-text{display:var(--dsh-composer-model-text-display,inline);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:180px}",
91
- ".es-trigger-effort{display:var(--dsh-composer-model-text-display,inline);font-weight:600;color:hsl(var(--es-hue) 100% 84%);text-shadow:0 0 10px hsl(var(--es-hue) 100% 70% / .45)}",
92
- ".es-trigger-sep{display:var(--dsh-composer-model-text-display,inline);opacity:.38;margin:0 1px}",
93
- ".es-trigger-chev{display:inline-flex;width:10px;height:10px;flex:none;opacity:.55;margin-left:1px}",
94
-
95
- ".es-menu{--es-hue:214;--es-t:0;--es-e:0;position:fixed;z-index:2147483000;display:flex;flex-direction:column;width:min(400px,calc(100vw - 24px));max-height:calc(100vh - 24px);border-radius:20px;background:rgba(11,14,21,.93);border:1px solid rgba(255,255,255,.1);backdrop-filter:blur(24px) saturate(150%);-webkit-backdrop-filter:blur(24px) saturate(150%);box-shadow:0 26px 70px rgba(0,0,0,.62),inset 0 1px 0 rgba(255,255,255,.06);overflow:hidden;color:#e8ecf7;isolation:isolate;animation:es-pop-menu .2s cubic-bezier(.2,1.1,.3,1) both}",
96
- ".es-menu.es-quake{animation:es-quake-menu .52s cubic-bezier(.36,.07,.19,.97) both}",
97
- ".es-menu-bg{position:absolute;inset:0;width:100%;height:100%;display:block;pointer-events:none;opacity:.9}",
98
- ".es-menu-vig{position:absolute;inset:0;pointer-events:none;background:radial-gradient(120% 100% at 50% 40%,transparent 42%,rgba(0,0,0,.55) 100%)}",
99
- ".es-menu-body{position:relative;display:flex;flex-direction:column;flex:1 1 auto;min-height:0;overflow:hidden;padding:9px 11px 11px}",
100
- ".es-menu-row{display:flex;align-items:center;gap:8px;width:100%;padding:8px 10px;border:0;border-radius:12px;background:transparent;color:#e8ecf7;font:inherit;font-size:13px;cursor:pointer;text-align:left;transition:background .18s ease}",
101
- ".es-menu-row:hover{background:rgba(255,255,255,.06)}",
102
- ".es-menu-row-label{flex:none;color:#8b93a6;font-size:12px}",
103
- ".es-menu-row-value{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600;color:#dfe6f5}",
104
- ".es-menu-chev{display:inline-flex;width:11px;height:11px;flex:none;opacity:.55;transition:transform .22s ease}",
105
- '.es-menu-row[data-open="1"] .es-menu-chev{transform:rotate(180deg)}',
106
- ".es-models{display:flex;flex-direction:column;gap:1px;flex:0 1 auto;min-height:0;max-height:216px;overflow:auto;margin:2px 0 2px;padding:4px 5px 5px;border-radius:12px;background:rgba(6,8,13,.66);box-shadow:inset 0 1px 0 rgba(255,255,255,.05);scrollbar-width:thin;overscroll-behavior:contain}",
107
- ".es-models-group{padding:7px 6px 3px;font-size:10.5px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:#8d96aa}",
108
- ".es-model-item{display:flex;align-items:center;gap:8px;padding:7px 10px;border:0;border-radius:9px;background:transparent;color:#ccd5e6;font:inherit;font-size:12.5px;cursor:pointer;text-align:left;transition:background .16s ease,color .16s ease}",
109
- ".es-model-item:hover{background:rgba(255,255,255,.07);color:#fff}",
110
- '.es-model-item[data-on="1"]{color:#fff;background:hsl(var(--es-hue) 100% 62% / .2);box-shadow:inset 2px 0 0 hsl(var(--es-hue) 100% 72%)}',
111
- ".es-model-item-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
112
- ".es-model-item-tag{flex:none;font-size:10px;color:#7f8798}",
113
- ".es-menu-sep{height:1px;margin:6px 2px 0;background:linear-gradient(90deg,transparent,rgba(255,255,255,.14),transparent)}",
114
- ".es-effort-head{display:flex;align-items:center;gap:7px;padding:9px 10px 7px;font-size:11px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:#8b93a6}",
115
- ".es-effort-head .es-head-glyph{display:inline-flex;width:13px;height:13px;flex:none}",
116
- ".es-effort-head .es-tag{margin-left:auto;text-transform:none}",
117
- ".es-tag{font-size:11px;font-weight:700;letter-spacing:.06em;padding:2px 7px;border-radius:999px;border:1px solid hsl(var(--es-hue) 100% 72% / .38);color:hsl(var(--es-hue) 100% 84%);background:hsl(var(--es-hue) 100% 62% / .12);white-space:nowrap}",
118
- ".es-menu .es-track{height:52px;margin:0 2px}",
119
- ".es-menu .es-scale{margin:7px 2px 0}",
120
- ".es-menu .es-foot{padding:8px 2px 0}",
121
- ".es-menu .es-big{top:44%;font-size:clamp(19px,6vw,30px);letter-spacing:.12em}",
122
- ".es-track{position:relative;height:62px;border-radius:999px;touch-action:none;cursor:pointer;user-select:none;-webkit-user-select:none;background:radial-gradient(120% 180% at 50% 50%,rgba(255,255,255,.045),rgba(255,255,255,.012) 60%,rgba(0,0,0,.25));border:1px solid rgba(255,255,255,.07);box-shadow:inset 0 2px 14px rgba(0,0,0,.7),0 0 0 1px rgba(0,0,0,.35)}",
123
- ".es-rail{position:absolute;inset:0;width:100%;height:100%;border-radius:999px;display:block;pointer-events:none}",
124
- ".es-thumb{position:absolute;top:50%;left:calc(31px + (100% - 62px) * var(--es-t,0));width:40px;height:40px;border-radius:50%;transform:translate(-50%,-50%);background:radial-gradient(circle at 38% 34%,#fff 0%,#fff 42%,#dbe6ff 62%,#9fb6ff 100%);box-shadow:0 0 18px rgba(255,255,255,.95),0 0 46px hsl(var(--es-hue) 100% 72% / .85),0 0 90px hsl(var(--es-hue) 100% 62% / .55),inset 0 -3px 10px rgba(120,150,255,.5);pointer-events:none;transition:box-shadow .35s ease}",
125
- ".es-thumb::after{content:'';position:absolute;inset:-6px;border-radius:50%;background:radial-gradient(circle,hsl(var(--es-hue) 100% 75% / .35),transparent 68%);filter:blur(3px)}",
126
- ".es-ticks{position:absolute;inset:0;pointer-events:none}",
127
- ".es-tick{position:absolute;top:50%;width:3px;height:3px;border-radius:50%;transform:translate(-50%,-50%);background:rgba(255,255,255,.22);left:calc(31px + (100% - 62px) * var(--i,0))}",
128
- '.es-tick[data-on="1"]{background:#fff;box-shadow:0 0 8px #fff,0 0 18px hsl(var(--es-hue) 100% 70% / .9)}',
129
- ".es-scale{position:relative;height:15px;margin:9px 0 0;padding:0;font-size:11px;color:#6d7588}",
130
- ".es-scale-item{position:absolute;top:0;transform:translateX(-50%);padding:0 5px;border:0;background:none;font:inherit;color:inherit;cursor:pointer;white-space:nowrap;transition:color .2s ease,text-shadow .3s ease}",
131
- ".es-scale-item:hover{color:#cfd8ea}",
132
- '.es-scale-item[data-on="1"]{color:#fff;text-shadow:0 0 12px hsl(var(--es-hue) 100% 72% / .9)}',
133
- ".es-foot{display:flex;align-items:center;gap:8px;padding:10px 2px 0;font-size:11.5px;color:#7f8798;min-height:18px;line-height:1.4}",
134
- '.es-foot[data-err="1"]{color:#ff9a9a}',
135
- '.es-foot[data-note="1"]{color:hsl(var(--es-hue) 100% 84%)}',
136
- ".es-fx{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;mix-blend-mode:screen;display:block}",
137
- ".es-flash{position:absolute;inset:0;pointer-events:none;opacity:0;background:radial-gradient(60% 60% at 50% 50%,#fff,rgba(190,215,255,.6) 45%,transparent 72%)}",
138
- ".es-flash.go{animation:es-flash 1.1s ease-out both}",
139
- ".es-big{position:absolute;left:50%;top:38%;transform:translate(-50%,-50%);pointer-events:none;opacity:0;font-size:clamp(34px,11vw,86px);font-weight:900;letter-spacing:.16em;color:#fff;white-space:nowrap;text-shadow:0 0 22px rgba(255,255,255,.85),0 0 70px hsl(var(--es-hue) 100% 65% / .9),0 0 140px hsl(var(--es-hue) 100% 60% / .6)}",
140
- ".es-big.go{animation:es-big 3.2s cubic-bezier(.2,.7,.3,1) both}",
141
- "@keyframes es-pop-menu{0%{opacity:0;transform:translateY(7px) scale(.97)}100%{opacity:1;transform:translateY(0) scale(1)}}",
142
- "@keyframes es-flash{0%{opacity:0}6%{opacity:.95}22%{opacity:.55}100%{opacity:0}}",
143
- "@keyframes es-big{0%{opacity:0;transform:translate(-50%,-50%) scale(.7);filter:blur(10px)}18%{opacity:1;transform:translate(-50%,-50%) scale(1.04);filter:blur(0)}62%{opacity:1;transform:translate(-50%,-50%) scale(1)}100%{opacity:0;transform:translate(-50%,-50%) scale(1.1);filter:blur(6px)}}",
144
- "@keyframes es-quake-menu{0%{transform:translate(0,0) rotate(0)}12%{transform:translate(-6px,3px) rotate(-.4deg)}24%{transform:translate(5px,-4px) rotate(.36deg)}38%{transform:translate(-4px,-2px) rotate(-.24deg)}52%{transform:translate(3px,3px) rotate(.24deg)}68%{transform:translate(-2px,1px) rotate(-.14deg)}84%{transform:translate(1px,-1px) rotate(.08deg)}100%{transform:translate(0,0) rotate(0)}}",
145
- "@media (prefers-reduced-motion:reduce){.es-menu{animation:none!important}.es-menu.es-quake{animation:none!important}.es-flash.go,.es-big.go{animation-duration:.4s}.es-thumb{transition:none}}",
146
- "@media (max-height:660px){.es-menu .es-track{height:46px}.es-models{max-height:150px}}"
147
- ].join("\n");
148
-
149
- function injectCss() {
150
- if (typeof document === "undefined") return;
151
- const existing = document.querySelector("style[data-plugin-css=" + JSON.stringify(CSS_TAG) + "]");
152
- if (existing !== null) return;
153
- const tag = document.createElement("style");
154
- tag.dataset.plugin = "@lolannn/dsh-effort-slider";
155
- tag.dataset.pluginCss = CSS_TAG;
156
- tag.textContent = CSS;
157
- document.head.appendChild(tag);
158
- }
159
-
160
- /* ------------------------------------------------------------------ *
161
- * small helpers
162
- * ------------------------------------------------------------------ */
163
-
164
- function readService(read) {
165
- try {
166
- return read();
167
- } catch (err) {
168
- return void 0;
169
- }
170
- }
171
-
172
- /*
173
- * Never let a label rendered inside a <button> look like a filesystem path.
174
- *
175
- * The pocket client installs a document-level click guard that inspects
176
- * event.target.closest("button, a").textContent; when it matches
177
- * /[\w.\-]+\/[\w.\-]+\.\w{1,12}/ (or a leading "/", "~/", "C:\") it calls
178
- * preventDefault() + stopImmediatePropagation() and shows a "cannot open
179
- * computer files on your phone" toast. A model identifier such as
180
- * "workbuddy-ai/deepseek-v4.1-flash" matches that pattern, so on a phone the
181
- * trigger silently became unclickable. The slash is display-only: the real
182
- * identifier stays in aria-label/title and in the request we send.
183
- */
184
- function safeLabel(text) {
185
- if (text === void 0 || text === null) return "";
186
- return String(text).replace(/[\\/]+/g, " · ");
187
- }
188
-
189
- function clamp01(v) {
190
- return v < 0 ? 0 : v > 1 ? 1 : v;
191
- }
192
-
193
- function clamp(v, lo, hi) {
194
- return v < lo ? lo : v > hi ? hi : v;
195
- }
196
-
197
- function easeOutBack(p) {
198
- const c1 = 1.15;
199
- const c3 = c1 + 1;
200
- const q = p - 1;
201
- return 1 + c3 * q * q * q + c1 * q * q;
202
- }
203
-
204
- function messageOf(err) {
205
- if (!err) return "";
206
- if (typeof err === "string") return err;
207
- if (typeof err.message === "string") return err.message;
208
- try {
209
- return JSON.stringify(err);
210
- } catch (e) {
211
- return String(err);
212
- }
213
- }
214
-
215
- function withTimeout(work, ms) {
216
- return new Promise((resolve, reject) => {
217
- const timer = setTimeout(() => reject(new Error("timed out after " + ms + "ms")), ms);
218
- Promise.resolve(work).then(
219
- (value) => {
220
- clearTimeout(timer);
221
- resolve(value);
222
- },
223
- (err) => {
224
- clearTimeout(timer);
225
- reject(err);
226
- }
227
- );
228
- });
229
- }
230
-
231
- function mainViewSessionId(byId) {
232
- if (byId === void 0 || byId === null) return void 0;
233
- const rows = Object.values(byId);
234
- for (const row of rows) {
235
- if (row !== void 0 && (row.retainedBy && row.retainedBy.mainView ? row.retainedBy.mainView : 0) > 0) return row.id;
236
- }
237
- return void 0;
238
- }
239
-
240
- function createStore(initial) {
241
- let state = initial;
242
- const listeners = new Set();
243
- return {
244
- getSnapshot() {
245
- return state;
246
- },
247
- set(patch) {
248
- const next = Object.assign({}, state, typeof patch === "function" ? patch(state) : patch);
249
- state = next;
250
- listeners.forEach((fn) => {
251
- try {
252
- fn();
253
- } catch (err) {
254
- /* a broken subscriber must not break the store */
255
- }
256
- });
257
- },
258
- subscribe(fn) {
259
- listeners.add(fn);
260
- return () => {
261
- listeners.delete(fn);
262
- };
263
- }
264
- };
265
- }
266
-
267
- /* ------------------------------------------------------------------ *
268
- * level -> visual spec
269
- * ------------------------------------------------------------------ */
270
-
271
- function topIndexOf(count) {
272
- return Math.max(0, count - 1);
273
- }
274
-
275
- function levelT(index, count) {
276
- const top = topIndexOf(count);
277
- return top > 0 ? clamp(index, 0, top) / top : 0;
278
- }
279
-
280
- function hueAtT(t) {
281
- return t >= 0.92 ? 188 : 200 + 106 * t;
282
- }
283
-
284
- function kindAtT(t) {
285
- if (t <= 0.001) return "none";
286
- if (t < 0.3) return "dot";
287
- if (t < 0.5) return "trail";
288
- if (t < 0.75) return "star";
289
- if (t < 0.92) return "starHeavy";
290
- return "galaxy";
291
- }
292
-
293
- function countAtT(kind, t) {
294
- if (kind === "dot") return 10 + Math.round(8 * t);
295
- if (kind === "trail") return 18 + Math.round(10 * t);
296
- if (kind === "star") return 28;
297
- if (kind === "starHeavy") return 40;
298
- if (kind === "galaxy") return 38;
299
- return 0;
300
- }
301
-
302
- function specAt(t) {
303
- const kind = kindAtT(t);
304
- return {
305
- t,
306
- hue: hueAtT(t),
307
- kind,
308
- ribbon: kind !== "none" && kind !== "galaxy" && t >= 0.55,
309
- n: countAtT(kind, t)
310
- };
311
- }
312
-
313
- /* ------------------------------------------------------------------ *
314
- * deterministic randomness for rail particles
315
- * ------------------------------------------------------------------ */
316
-
317
- function hashStr(s) {
318
- let h1 = 2166136261;
319
- for (let i = 0; i < s.length; i++) {
320
- h1 ^= s.charCodeAt(i);
321
- h1 = Math.imul(h1, 16777619);
322
- }
323
- return h1 >>> 0;
324
- }
325
-
326
- function mulberry32(a) {
327
- let seed = a >>> 0;
328
- return function () {
329
- seed = (seed + 0x6d2b79f5) >>> 0;
330
- let t = seed;
331
- t = Math.imul(t ^ (t >>> 15), 1 | t);
332
- t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
333
- return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
334
- };
335
- }
336
-
337
- /* ------------------------------------------------------------------ *
338
- * canvas primitives
339
- * ------------------------------------------------------------------ */
340
-
341
- function roundRect(c, x, y, w, hh, r) {
342
- const rr = Math.min(r, w / 2, hh / 2);
343
- c.beginPath();
344
- c.moveTo(x + rr, y);
345
- c.arcTo(x + w, y, x + w, y + hh, rr);
346
- c.arcTo(x + w, y + hh, x, y + hh, rr);
347
- c.arcTo(x, y + hh, x, y, rr);
348
- c.arcTo(x, y, x + w, y, rr);
349
- c.closePath();
350
- }
351
-
352
- function starPath(c, x, y, r) {
353
- const k = r * 0.07;
354
- c.beginPath();
355
- c.moveTo(x, y - r);
356
- c.quadraticCurveTo(x + k, y - k, x + r, y);
357
- c.quadraticCurveTo(x + k, y + k, x, y + r);
358
- c.quadraticCurveTo(x - k, y + k, x - r, y);
359
- c.quadraticCurveTo(x - k, y - k, x, y - r);
360
- c.closePath();
361
- }
362
-
363
- function star4(c, x, y, r, alpha, hue, irid) {
364
- if (r <= 0.06 || alpha <= 0.004) return;
365
- c.fillStyle = "hsla(" + hue + ",100%,92%," + alpha + ")";
366
- starPath(c, x, y, r);
367
- c.fill();
368
- if (!irid) return;
369
- c.fillStyle = "hsla(" + (hue + 155) + ",100%,70%," + alpha * 0.55 + ")";
370
- starPath(c, x, y, r * 0.78);
371
- c.fill();
372
- c.fillStyle = "hsla(" + (hue + 285) + ",100%,74%," + alpha * 0.42 + ")";
373
- starPath(c, x, y, r * 0.5);
374
- c.fill();
375
- }
376
-
377
- function starBeam(c, cx, cy, rx, ry) {
378
- const bow = 0.07;
379
- c.beginPath();
380
- c.moveTo(cx - rx, cy);
381
- c.quadraticCurveTo(cx, cy - ry * bow, cx + rx, cy);
382
- c.quadraticCurveTo(cx, cy + ry * bow, cx - rx, cy);
383
- c.closePath();
384
- c.fill();
385
- }
386
-
387
- function nebulaSprite(h1, h2) {
388
- const size = 256;
389
- const cv = document.createElement("canvas");
390
- cv.width = size;
391
- cv.height = size;
392
- const c = cv.getContext("2d");
393
- if (!c) return cv;
394
- const rnd = mulberry32(hashStr("neb:" + h1 + ":" + h2));
395
- for (let i = 0; i < 26; i++) {
396
- const x = size * (0.15 + rnd() * 0.7);
397
- const y = size * (0.15 + rnd() * 0.7);
398
- const r = size * (0.08 + rnd() * 0.26);
399
- const hue = i % 2 === 0 ? h1 : h2;
400
- const g = c.createRadialGradient(x, y, 0, x, y, r);
401
- g.addColorStop(0, "hsla(" + hue + ",95%,62%,.20)");
402
- g.addColorStop(0.45, "hsla(" + (hue + 26) + ",95%,52%,.07)");
403
- g.addColorStop(1, "hsla(" + hue + ",95%,50%,0)");
404
- c.fillStyle = g;
405
- c.beginPath();
406
- c.arc(x, y, r, 0, Math.PI * 2);
407
- c.fill();
408
- }
409
- return cv;
410
- }
411
-
412
- /* ------------------------------------------------------------------ *
413
- * engine
414
- * ------------------------------------------------------------------ */
415
-
416
- function createEngine() {
417
- const S = {
418
- mounted: false,
419
- raf: 0,
420
- last: 0,
421
- time: 0,
422
- frameNo: 0,
423
- t: 0,
424
- e: 0,
425
- idx: 0,
426
- count: 1,
427
- anim: null,
428
- dragging: false,
429
- warp: 0,
430
- topFired: false,
431
- reduce: false,
432
- els: null,
433
- bgc: null,
434
- rxc: null,
435
- fxc: null,
436
- W: 0,
437
- H: 0,
438
- dpr: 1,
439
- rw: 0,
440
- rh: 0,
441
- rdpr: 1,
442
- stars: [],
443
- waves: [],
444
- debris: [],
445
- streaks: [],
446
- astra: [],
447
- neb: null,
448
- parts: [],
449
- partKey: "",
450
- partKind: "",
451
- partFade: 1,
452
- prevParts: [],
453
- prevKind: "",
454
- prevFade: 0,
455
- texts: { boom: "宇宙毁灭", after: "星际探索" },
456
- onLevel: null,
457
- onRelease: null,
458
- bigTimer: 0,
459
- warpTimer: 0
460
- };
461
-
462
- function topIndex() {
463
- return topIndexOf(S.count);
464
- }
465
-
466
- /* ---------------- sizing ---------------- */
467
-
468
- function fit() {
469
- const els = S.els;
470
- if (!els || !els.bg || !els.track) return;
471
- S.dpr = Math.min(window.devicePixelRatio || 1, 2);
472
- /* Both canvases live inside the popup, so they must match the popup's
473
- own box — sizing them to the viewport would put the starfield centre
474
- far outside the card (and draw a full screen of pixels per frame). */
475
- let box = null;
476
- if (els.stage && typeof els.stage.getBoundingClientRect === "function") {
477
- const r = els.stage.getBoundingClientRect();
478
- if (r.width > 0.5 && r.height > 0.5) box = r;
479
- }
480
- S.W = Math.max(1, box ? box.width : window.innerWidth);
481
- S.H = Math.max(1, box ? box.height : window.innerHeight);
482
- const prep = (cv) => {
483
- if (!cv) return null;
484
- cv.width = Math.max(1, Math.round(S.W * S.dpr));
485
- cv.height = Math.max(1, Math.round(S.H * S.dpr));
486
- cv.style.width = S.W + "px";
487
- cv.style.height = S.H + "px";
488
- const c = cv.getContext("2d");
489
- if (c) c.setTransform(S.dpr, 0, 0, S.dpr, 0, 0);
490
- return c;
491
- };
492
- S.bgc = prep(els.bg);
493
- S.fxc = prep(els.fx);
494
- const r = els.track.getBoundingClientRect();
495
- S.rw = Math.max(1, r.width);
496
- S.rh = Math.max(1, r.height);
497
- S.rdpr = Math.min(window.devicePixelRatio || 1, 2);
498
- if (els.rail) {
499
- els.rail.width = Math.max(1, Math.round(S.rw * S.rdpr));
500
- els.rail.height = Math.max(1, Math.round(S.rh * S.rdpr));
501
- const rc = els.rail.getContext("2d");
502
- if (rc) rc.setTransform(S.rdpr, 0, 0, S.rdpr, 0, 0);
503
- S.rxc = rc;
504
- }
505
- }
506
-
507
- /* ---------------- starfield state ---------------- */
508
-
509
- function respawnStar(s) {
510
- s.x = Math.random() * 2 - 1;
511
- s.y = Math.random() * 2 - 1;
512
- s.z = 0.98;
513
- s.born = S.time;
514
- s.life = S.idx === topIndex() ? 6 + Math.random() * 5.5 : 4.2 + Math.random() * 4.2;
515
- }
516
-
517
- function initStars() {
518
- S.stars = [];
519
- for (let i = 0; i < 260; i++) {
520
- const s = {
521
- x: Math.random() * 2 - 1,
522
- y: Math.random() * 2 - 1,
523
- z: 0.08 + Math.random() * 0.92,
524
- c: Math.random() < 0.22 ? 1 : 0,
525
- born: 0,
526
- life: 4.2 + Math.random() * 4.2
527
- };
528
- S.stars.push(s);
529
- }
530
- }
531
-
532
- /* ---------------- rail particles ---------------- */
533
-
534
- function buildParticles(n, kind) {
535
- const key = kind + ":" + n;
536
- if (key === S.partKey) return;
537
- S.prevParts = S.parts;
538
- S.prevKind = S.partKind;
539
- S.prevFade = S.partFade;
540
- S.partKey = key;
541
- S.partKind = kind;
542
- S.partFade = 0;
543
- S.parts = [];
544
- if (n <= 0) return;
545
- const rnd = mulberry32(hashStr(key));
546
- for (let i = 0; i < n; i++) {
547
- S.parts.push({
548
- p0: rnd(),
549
- y: 0.1 + rnd() * 0.8,
550
- sp: 0.016 + rnd() * 0.058,
551
- size: 0.55 + rnd() * 1.5,
552
- hueOff: rnd() * 44 - 22,
553
- tw: rnd() * Math.PI * 2
554
- });
555
- }
556
- }
557
-
558
- function ensureParticles() {
559
- const spec = specAt(S.t);
560
- buildParticles(spec.n, spec.kind);
561
- }
562
-
563
- function drawParticleSet(c, list, kind, mul, hue, e, pad, reach, ih) {
564
- if (!list || !list.length || mul <= 0.002) return;
565
- const starMode = kind === "star" || kind === "starHeavy" || kind === "galaxy";
566
- const trailMode = starMode || kind === "trail";
567
- const speedK = 0.55 + e * 1.7;
568
- const base = kind === "dot" ? 0.9 : kind === "trail" ? 1.6 : 2.3;
569
- const copies = starMode ? (kind === "star" ? 2 : 4) : 4;
570
- const len = starMode ? (kind === "star" ? 15 : 10) : 10;
571
- for (let i = 0; i < list.length; i++) {
572
- const q = list[i];
573
- let p = (q.p0 - S.time * q.sp * speedK) % 1;
574
- if (p < 0) p += 1;
575
- const x = pad + reach * p;
576
- let y = pad + ih * q.y;
577
- if (kind === "dot") y += Math.sin(q.tw + S.time * 1.3) * 1.2;
578
- const flick = 0.62 + 0.38 * Math.sin(q.tw + S.time * 2.6);
579
- const a = (0.2 + e * 0.62) * flick * mul;
580
- if (a <= 0.004) continue;
581
- const sz = base * (0.7 + q.size * 0.55);
582
- if (trailMode) {
583
- for (let k = 1; k <= copies; k++) {
584
- const tx = x + len * k;
585
- if (tx > pad + reach) break;
586
- const ak = a * (1 - k / (copies + 1)) * 0.55;
587
- if (starMode) star4(c, tx, y, sz * (1 - k * 0.08), ak * 0.75, hue + q.hueOff + 20, false);
588
- else {
589
- c.fillStyle = "hsla(" + (hue + q.hueOff) + ",100%,78%," + ak + ")";
590
- c.beginPath();
591
- c.arc(tx, y, Math.max(0.3, sz * 0.8), 0, Math.PI * 2);
592
- c.fill();
593
- }
594
- }
595
- }
596
- if (starMode) star4(c, x, y, sz * (kind === "starHeavy" ? 1.4 : 1), a, hue + q.hueOff + 30, true);
597
- else {
598
- c.fillStyle = "hsla(" + (hue + q.hueOff) + ",100%,80%," + a + ")";
599
- c.beginPath();
600
- c.arc(x, y, Math.max(0.3, sz * (0.7 + e * 0.8)), 0, Math.PI * 2);
601
- c.fill();
602
- }
603
- }
604
- }
605
-
606
- /* ---------------- background ---------------- */
607
-
608
- function drawBG(dt) {
609
- const c = S.bgc;
610
- if (!c) return;
611
- const W = S.W;
612
- const H = S.H;
613
- const e = S.e;
614
- const hue = hueAtT(S.t);
615
- c.setTransform(S.dpr, 0, 0, S.dpr, 0, 0);
616
- c.globalCompositeOperation = "source-over";
617
- c.clearRect(0, 0, W, H);
618
- const g = c.createRadialGradient(W * 0.5, H * 0.45, 0, W * 0.5, H * 0.45, Math.max(W, H) * 0.85);
619
- g.addColorStop(0, "hsla(" + (222 + e * 60) + "," + (46 + e * 26) + "%," + (5 + e * 4) + "%,.94)");
620
- g.addColorStop(1, "rgba(3,4,10,.97)");
621
- c.fillStyle = g;
622
- c.fillRect(0, 0, W, H);
623
-
624
- c.globalCompositeOperation = "lighter";
625
-
626
- if (S.neb) {
627
- const sway = Math.sin(S.time * 0.09) * 40;
628
- const layers = [
629
- [S.neb.a, 0.12 + e * 0.3, 0.3, sway],
630
- [S.neb.b, 0.1 + e * 0.26, 0.72, -sway * 0.7],
631
- [S.neb.c, 0.08 + e * 0.22, 0.5, sway * 0.45]
632
- ];
633
- for (let i = 0; i < layers.length; i++) {
634
- const spr = layers[i][0];
635
- if (!spr) continue;
636
- const w = W * 1.5;
637
- const hh = H * 1.5;
638
- c.globalAlpha = Math.min(1, layers[i][1]);
639
- c.drawImage(spr, W * layers[i][2] - w / 2 + layers[i][3], H * 0.5 - hh / 2 + layers[i][3] * 0.4, w, hh);
640
- }
641
- c.globalAlpha = 1;
642
- }
643
-
644
- const speed = 0.045 + e * 0.55 + S.warp * 2.4;
645
- const fov = Math.min(W, H) * 0.62;
646
- const cx = W * 0.5;
647
- const cy = H * 0.46;
648
- for (let i = 0; i < S.stars.length; i++) {
649
- const s = S.stars[i];
650
- s.z -= dt * speed * (0.35 + (1 - s.z) * 1.6);
651
- const age = S.time - s.born;
652
- if (s.z <= 0.012 || age > s.life) {
653
- respawnStar(s);
654
- continue;
655
- }
656
- const k = fov / s.z;
657
- const near = 1 - s.z;
658
- const x = cx + s.x * k;
659
- const y = cy + s.y * k;
660
- if (x < -80 || x > W + 80 || y < -80 || y > H + 80) {
661
- respawnStar(s);
662
- continue;
663
- }
664
- const lifeA = Math.min(1, age / 0.7) * Math.max(0, 1 - age / s.life);
665
- const a = Math.min(1, near * 1.5) * (0.35 + e * 0.6) * lifeA;
666
- if (a <= 0.004) continue;
667
- const tint = s.c ? 268 : 196;
668
- if (S.warp > 0.04 && near > 0.22) {
669
- const pz = Math.max(0.02, s.z + dt * speed * 1.6);
670
- const pk = fov / pz;
671
- c.strokeStyle = "hsla(" + tint + ",100%,82%," + a * 0.85 + ")";
672
- c.lineWidth = Math.min(3.2, 0.5 + near * 2.4);
673
- c.beginPath();
674
- c.moveTo(x, y);
675
- c.lineTo(cx + s.x * pk, cy + s.y * pk);
676
- c.stroke();
677
- } else {
678
- c.fillStyle = "hsla(" + tint + ",100%,88%," + a + ")";
679
- c.beginPath();
680
- c.arc(x, y, Math.min(2.4, 0.35 + near * 1.9), 0, Math.PI * 2);
681
- c.fill();
682
- }
683
- }
684
-
685
- for (let i = S.waves.length - 1; i >= 0; i--) {
686
- const w = S.waves[i];
687
- w.r += dt * (900 + 900 * w.a);
688
- w.a *= Math.pow(0.18, dt);
689
- if (w.a < 0.012) {
690
- S.waves.splice(i, 1);
691
- continue;
692
- }
693
- c.strokeStyle = "hsla(" + w.hue + ",100%,76%," + w.a + ")";
694
- c.lineWidth = w.w * Math.max(0.2, w.a);
695
- c.beginPath();
696
- c.arc(cx, cy, w.r, 0, Math.PI * 2);
697
- c.stroke();
698
- }
699
-
700
- for (let i = S.debris.length - 1; i >= 0; i--) {
701
- const d = S.debris[i];
702
- d.life -= dt * 0.42;
703
- if (d.life <= 0) {
704
- S.debris.splice(i, 1);
705
- continue;
706
- }
707
- d.vy += 120 * dt;
708
- d.vx *= Math.pow(0.55, dt);
709
- d.x += d.vx * dt;
710
- d.y += d.vy * dt;
711
- const a = Math.min(1, d.life) * 0.9;
712
- c.fillStyle = "hsla(" + d.hue + ",100%,82%," + a + ")";
713
- c.beginPath();
714
- c.arc(d.x, d.y, d.size * Math.max(0.3, d.life), 0, Math.PI * 2);
715
- c.fill();
716
- }
717
-
718
- for (let i = S.streaks.length - 1; i >= 0; i--) {
719
- const s = S.streaks[i];
720
- s.life -= dt * 1.9;
721
- if (s.life <= 0) {
722
- S.streaks.splice(i, 1);
723
- continue;
724
- }
725
- s.x += s.vx * dt;
726
- s.y += s.vy * dt;
727
- const a = Math.min(1, s.life) * 0.75;
728
- c.strokeStyle = "hsla(" + s.hue + ",100%,86%," + a + ")";
729
- c.lineWidth = 1.4;
730
- c.beginPath();
731
- c.moveTo(s.x, s.y);
732
- c.lineTo(s.x - s.vx * 0.055, s.y - s.vy * 0.055);
733
- c.stroke();
734
- }
735
-
736
- c.globalCompositeOperation = "source-over";
737
- }
738
-
739
- /* ---------------- the rail ---------------- */
740
-
741
- function drawRail() {
742
- const c = S.rxc;
743
- if (!c) return;
744
- const w = S.rw;
745
- const hh = S.rh;
746
- c.setTransform(S.rdpr, 0, 0, S.rdpr, 0, 0);
747
- c.clearRect(0, 0, w, hh);
748
- const spec = specAt(S.t);
749
- const e = S.e;
750
- const hue = hueAtT(S.t);
751
- const pad = 11;
752
- const iw = Math.max(1, w - pad * 2);
753
- const ih = Math.max(1, hh - pad * 2);
754
- const tx = EDGE + (w - EDGE * 2) * S.t;
755
- const reach = Math.max(1, tx - pad);
756
- const cyy = hh * 0.5;
757
-
758
- c.save();
759
- roundRect(c, pad, pad, iw, ih, ih / 2);
760
- c.clip();
761
-
762
- const bgGrad = c.createLinearGradient(pad, 0, pad + iw, 0);
763
- bgGrad.addColorStop(0, "hsla(" + (hue - 42) + ",72%," + (8 + e * 4) + "%,.96)");
764
- bgGrad.addColorStop(0.5, "hsla(" + hue + ",72%," + (10 + e * 6) + "%,.96)");
765
- bgGrad.addColorStop(1, "hsla(" + (hue + 46) + ",72%," + (9 + e * 5) + "%,.96)");
766
- c.fillStyle = bgGrad;
767
- c.fillRect(pad, pad, iw, ih);
768
-
769
- const fillGrad = c.createLinearGradient(pad, 0, pad + Math.max(1, reach), 0);
770
- fillGrad.addColorStop(0, "hsla(" + (hue - 34) + ",100%,62%,.55)");
771
- fillGrad.addColorStop(0.45, "hsla(" + hue + ",100%,66%,.72)");
772
- fillGrad.addColorStop(0.8, "hsla(" + (hue + 40) + ",100%,68%,.82)");
773
- fillGrad.addColorStop(1, "hsla(" + (hue + 64) + ",100%,74%,.92)");
774
- c.fillStyle = fillGrad;
775
- c.fillRect(pad, pad, reach, ih);
776
-
777
- c.globalCompositeOperation = "lighter";
778
-
779
- if (spec.ribbon) {
780
- const slow = S.time * (0.28 + e * 0.5);
781
- for (let k = 0; k < 3; k++) {
782
- const amp = (3 + k * 3.4) * (0.35 + e);
783
- c.beginPath();
784
- for (let i = 0; i <= 60; i++) {
785
- const p = i / 60;
786
- const x = pad + iw * p;
787
- const y =
788
- cyy +
789
- Math.sin(p * (5.2 + k * 1.7) + slow * (1 + k * 0.35) + k) * amp +
790
- Math.sin(p * 12 + slow * 1.7) * amp * 0.28;
791
- if (i === 0) c.moveTo(x, y);
792
- else c.lineTo(x, y);
793
- }
794
- c.strokeStyle = "hsla(" + (hue + 30 + k * 18) + ",100%,78%," + (0.1 + e * 0.26) + ")";
795
- c.lineWidth = 1.1 + e * 1.5;
796
- c.stroke();
797
- }
798
- }
799
-
800
- c.save();
801
- c.beginPath();
802
- c.rect(pad, pad, reach, ih);
803
- c.clip();
804
- if (S.prevParts.length && S.prevFade > 0.01) drawParticleSet(c, S.prevParts, S.prevKind, S.prevFade, hue, e, pad, reach, ih);
805
- drawParticleSet(c, S.parts, S.partKind, S.partFade, hue, e, pad, reach, ih);
806
-
807
- if (spec.kind === "starHeavy") {
808
- const sc = 0.6 + e * 0.7;
809
- const br = 0.5 + 0.5 * Math.sin(S.time * 2.4);
810
- const cxx = pad + iw * 0.5;
811
- const halo = c.createRadialGradient(cxx, cyy, 0, cxx, cyy, 26 * sc);
812
- halo.addColorStop(0, "hsla(" + hue + ",100%,90%," + (0.22 + br * 0.3) + ")");
813
- halo.addColorStop(1, "hsla(" + hue + ",100%,70%,0)");
814
- c.fillStyle = halo;
815
- c.beginPath();
816
- c.arc(cxx, cyy, 26 * sc, 0, Math.PI * 2);
817
- c.fill();
818
- star4(c, cxx, cyy, (13 + br * 3) * sc, 0.75 + br * 0.2, hue, true);
819
- star4(c, cxx - iw * 0.17, cyy, 7 * sc, 0.42, hue + 40, false);
820
- star4(c, cxx + iw * 0.17, cyy, 7 * sc, 0.42, hue - 30, false);
821
- }
822
-
823
- if (spec.kind === "galaxy") {
824
- const ga = clamp01((e - 0.9) / 0.1);
825
- const cxx = pad + iw * 0.5;
826
- const core = c.createRadialGradient(cxx, cyy, 0, cxx, cyy, iw * 0.24);
827
- core.addColorStop(0, "rgba(255,255,255,.95)");
828
- core.addColorStop(0.35, "hsla(196,100%,82%,.75)");
829
- core.addColorStop(0.72, "hsla(258,100%,62%,.34)");
830
- core.addColorStop(1, "hsla(258,100%,60%,0)");
831
- c.fillStyle = core;
832
- c.beginPath();
833
- c.arc(cxx, cyy, iw * 0.24, 0, Math.PI * 2);
834
- c.fill();
835
- for (let a = 0; a < 3; a++) {
836
- for (let i = 0; i < 130; i++) {
837
- const f = i / 130;
838
- const ang = a * ((Math.PI * 2) / 3) + f * 5.4 + S.time * 0.55;
839
- const rad = f * iw * 0.46;
840
- const x = cxx + Math.cos(ang) * rad;
841
- const y = cyy + Math.sin(ang) * rad * 0.42;
842
- const alpha = (1 - f) * 0.5 * (0.4 + ga * 0.6);
843
- c.fillStyle = "hsla(" + (196 + a * 32) + ",100%," + (86 - f * 18) + "%," + alpha + ")";
844
- c.beginPath();
845
- c.arc(x, y, 0.5 + (1 - f) * 1.5, 0, Math.PI * 2);
846
- c.fill();
847
- }
848
- }
849
- }
850
- c.restore();
851
-
852
- const gloss = c.createLinearGradient(0, pad, 0, pad + ih);
853
- gloss.addColorStop(0, "rgba(255,255,255,.16)");
854
- gloss.addColorStop(0.35, "rgba(255,255,255,.05)");
855
- gloss.addColorStop(0.55, "rgba(255,255,255,0)");
856
- gloss.addColorStop(1, "rgba(0,0,0,.18)");
857
- c.fillStyle = gloss;
858
- c.fillRect(pad, pad, iw, ih);
859
-
860
- c.globalCompositeOperation = "source-over";
861
- c.restore();
862
-
863
- c.strokeStyle = "hsla(" + hue + ",100%,72%," + (0.18 + e * 0.5) + ")";
864
- c.lineWidth = 1.2;
865
- c.shadowColor = "hsla(" + hue + ",100%,68%,.9)";
866
- c.shadowBlur = 10 + e * 26;
867
- roundRect(c, pad + 0.5, pad + 0.5, iw - 1, ih - 1, ih / 2);
868
- c.stroke();
869
- c.shadowBlur = 0;
870
- }
871
-
872
- /* ---------------- light blades ---------------- */
873
-
874
- function drawFx(dt) {
875
- const c = S.fxc;
876
- if (!c) return;
877
- c.setTransform(S.dpr, 0, 0, S.dpr, 0, 0);
878
- c.clearRect(0, 0, S.W, S.H);
879
- if (!S.astra.length || !S.els || !S.els.track) return;
880
- const r = S.els.track.getBoundingClientRect();
881
- const cx = r.left + r.width / 2;
882
- const cy = r.top + r.height / 2;
883
- const k = Math.min(1.25, 0.35 + (r.width / 900) * 0.9);
884
- for (let i = S.astra.length - 1; i >= 0; i--) {
885
- const f = S.astra[i];
886
- f.age += dt;
887
- const p = f.age / f.life;
888
- if (p >= 1) {
889
- S.astra.splice(i, 1);
890
- continue;
891
- }
892
- const p2 = p * p;
893
- const th = 13 * (1 - p2) * (1 - p2 * 0.35);
894
- const baseRy = r.height * 0.55;
895
- const halfL = r.width * 0.62;
896
- for (let L = 0; L < 14; L++) {
897
- const lt = L / 13;
898
- const a = (0.022 + 0.95 * Math.pow(lt, 2.3)) * (1 - p2) * 0.8;
899
- if (a <= 0.004) continue;
900
- const col = Math.round(90 + 165 * Math.pow(lt, 0.7)) + "," + Math.round(150 + 105 * Math.pow(lt, 0.5)) + ",255";
901
- c.fillStyle = "rgba(" + col + "," + a + ")";
902
- const ry = Math.max(0.5, th * (0.4 + L * 0.06));
903
- starBeam(c, cx, cy, halfL * (0.55 + L * 0.03), ry);
904
- c.save();
905
- c.translate(cx, cy);
906
- c.rotate(Math.PI / 2);
907
- c.translate(-cx, -cy);
908
- starBeam(c, cx, cy, baseRy * (0.35 + L * 0.05), Math.max(0.4, ry * 0.45));
909
- c.restore();
910
- }
911
- const cr = Math.max(1, (8 + 34 * (1 - p2)) * k);
912
- const g = c.createRadialGradient(cx, cy, 0, cx, cy, cr);
913
- g.addColorStop(0, "rgba(255,255,255," + 0.85 * (1 - p2) + ")");
914
- g.addColorStop(0.5, "rgba(190,215,255," + 0.35 * (1 - p2) + ")");
915
- g.addColorStop(1, "rgba(120,160,255,0)");
916
- c.fillStyle = g;
917
- c.beginPath();
918
- c.arc(cx, cy, cr, 0, Math.PI * 2);
919
- c.fill();
920
- }
921
- }
922
-
923
- /* ---------------- celebration ---------------- */
924
-
925
- function spawnStreaks() {
926
- for (let i = 0; i < 24; i++) {
927
- const ang = Math.random() * Math.PI * 2;
928
- const sp = 380 + Math.random() * 980;
929
- S.streaks.push({
930
- x: S.W * 0.5,
931
- y: S.H * 0.5,
932
- vx: Math.cos(ang) * sp,
933
- vy: Math.sin(ang) * sp,
934
- life: 1,
935
- hue: 180 + Math.random() * 150
936
- });
937
- }
938
- }
939
-
940
- function boom() {
941
- const els = S.els;
942
- if (!els) return;
943
- S.astra.push({ age: 0, life: 0.9 });
944
- if (S.reduce) return;
945
- if (els.stage && els.stage.classList) {
946
- els.stage.classList.remove("es-quake");
947
- void els.stage.offsetWidth;
948
- els.stage.classList.add("es-quake");
949
- }
950
- if (els.flash && els.flash.classList) {
951
- els.flash.classList.remove("go");
952
- void els.flash.offsetWidth;
953
- els.flash.classList.add("go");
954
- }
955
- if (els.big && els.big.classList) {
956
- els.big.textContent = S.texts.boom;
957
- els.big.classList.remove("go");
958
- void els.big.offsetWidth;
959
- els.big.classList.add("go");
960
- clearTimeout(S.bigTimer);
961
- S.bigTimer = setTimeout(() => {
962
- if (els.big) els.big.textContent = S.texts.after;
963
- }, 2900);
964
- }
965
- const alphas = [0.95, 0.7, 0.5, 0.35];
966
- const widths = [6, 14, 26, 40];
967
- const hues = [190, 300, 46, 210];
968
- for (let i = 0; i < 4; i++) S.waves.push({ r: 0, a: alphas[i], w: widths[i], hue: hues[i] });
969
- for (let i = 0; i < 220; i++) {
970
- const ang = Math.random() * Math.PI * 2;
971
- const sp = 60 + Math.random() * 620;
972
- S.debris.push({
973
- x: S.W * 0.5,
974
- y: S.H * 0.5,
975
- vx: Math.cos(ang) * sp,
976
- vy: Math.sin(ang) * sp,
977
- life: 1,
978
- hue: [190, 300, 45][i % 3],
979
- size: 0.6 + Math.random() * 2.2
980
- });
981
- }
982
- clearTimeout(S.warpTimer);
983
- S.warpTimer = setTimeout(() => {
984
- S.warp = 1;
985
- }, 220);
986
- }
987
-
988
- /* ---------------- loop ---------------- */
989
-
990
- function syncLevel() {
991
- const top = topIndex();
992
- const i = top > 0 ? Math.round(S.t * top) : 0;
993
- if (i !== S.idx) {
994
- S.idx = i;
995
- if (S.onLevel) {
996
- try {
997
- S.onLevel(i);
998
- } catch (err) {
999
- /* a listener error must not stop the animation */
1000
- }
1001
- }
1002
- }
1003
- const isTop = top > 0 && i === top;
1004
- if (isTop && !S.topFired) {
1005
- S.topFired = true;
1006
- boom();
1007
- } else if (!isTop) {
1008
- S.topFired = false;
1009
- if (S.warp > 0.01) S.warp = 0;
1010
- }
1011
- }
1012
-
1013
- function paintDom() {
1014
- const el = S.els && S.els.stage;
1015
- if (!el || !el.style) return;
1016
- el.style.setProperty("--es-t", S.t.toFixed(4));
1017
- el.style.setProperty("--es-hue", String(Math.round(hueAtT(S.t))));
1018
- el.style.setProperty("--es-e", S.e.toFixed(3));
1019
- }
1020
-
1021
- function loop(now) {
1022
- if (!S.mounted) return;
1023
- S.raf = requestAnimationFrame(loop);
1024
- const dt = Math.min(0.05, S.last ? (now - S.last) / 1000 : 0.016);
1025
- S.last = now;
1026
- S.time += dt;
1027
- S.frameNo++;
1028
-
1029
- if (S.anim) {
1030
- const p = clamp01((now - S.anim.start) / S.anim.dur);
1031
- S.t = clamp01(S.anim.from + (S.anim.to - S.anim.from) * easeOutBack(p));
1032
- if (p >= 1) {
1033
- S.t = S.anim.to;
1034
- S.anim = null;
1035
- }
1036
- }
1037
-
1038
- S.e += (S.t - S.e) * Math.min(1, dt * (S.dragging ? 14 : 6));
1039
- S.partFade = Math.min(1, S.partFade + dt / 0.28);
1040
- if (S.prevFade > 0) S.prevFade = Math.max(0, S.prevFade - dt / 0.24);
1041
-
1042
- syncLevel();
1043
- ensureParticles();
1044
-
1045
- const top = topIndex();
1046
- if (!S.reduce && top > 0 && S.idx === top && S.frameNo % 10 === 0) spawnStreaks();
1047
-
1048
- drawBG(dt);
1049
- drawRail();
1050
- drawFx(dt);
1051
- paintDom();
1052
- }
1053
-
1054
- /* ---------------- pointer / keyboard ---------------- */
1055
-
1056
- function tFromEvent(ev) {
1057
- const el = S.els && S.els.track;
1058
- if (!el) return S.t;
1059
- const r = el.getBoundingClientRect();
1060
- const span = r.width - EDGE * 2;
1061
- if (span <= 1) return 0;
1062
- const x = clamp(ev.clientX - r.left, EDGE, r.width - EDGE);
1063
- return clamp01((x - EDGE) / span);
1064
- }
1065
-
1066
- function beginDrag(ev) {
1067
- if (!S.mounted) return;
1068
- S.dragging = true;
1069
- S.anim = null;
1070
- S.t = tFromEvent(ev);
1071
- try {
1072
- if (ev.currentTarget && ev.currentTarget.setPointerCapture) ev.currentTarget.setPointerCapture(ev.pointerId);
1073
- } catch (err) {
1074
- /* capture is a nicety */
1075
- }
1076
- ev.preventDefault();
1077
- }
1078
-
1079
- function onDocMove(ev) {
1080
- if (!S.dragging) return;
1081
- S.t = tFromEvent(ev);
1082
- }
1083
-
1084
- function onDocUp() {
1085
- if (!S.dragging) return;
1086
- S.dragging = false;
1087
- snapTo(levelT(S.idx, S.count), 300);
1088
- if (S.onRelease) {
1089
- try {
1090
- S.onRelease(S.idx);
1091
- } catch (err) {
1092
- /* ignore */
1093
- }
1094
- }
1095
- }
1096
-
1097
- function onKey(ev) {
1098
- if (!S.mounted) return;
1099
- /* The rail is now inline in the composer, so the caret may be in the
1100
- prompt box while this listener is live. Never steal arrow keys from
1101
- an editable target. */
1102
- const target = ev.target;
1103
- if (target && typeof target === "object") {
1104
- const tag = String(target.tagName || "").toUpperCase();
1105
- if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable === true) return;
1106
- }
1107
- const top = topIndex();
1108
- let next = null;
1109
- if (ev.key === "ArrowRight" || ev.key === "ArrowUp") next = Math.min(top, S.idx + 1);
1110
- else if (ev.key === "ArrowLeft" || ev.key === "ArrowDown") next = Math.max(0, S.idx - 1);
1111
- else if (ev.key === "Home") next = 0;
1112
- else if (ev.key === "End") next = top;
1113
- if (next === null) return;
1114
- ev.preventDefault();
1115
- snapTo(levelT(next, S.count), ev.key === "Home" || ev.key === "End" ? 420 : 300);
1116
- if (S.onRelease) {
1117
- try {
1118
- S.onRelease(next);
1119
- } catch (err) {
1120
- /* ignore */
1121
- }
1122
- }
1123
- }
1124
-
1125
- function snapTo(to, dur) {
1126
- S.anim = { from: S.t, to: clamp01(to), start: performance.now(), dur: dur || 300 };
1127
- }
1128
-
1129
- /* ---------------- lifecycle ---------------- */
1130
-
1131
- function mount(opts) {
1132
- S.els = opts.els;
1133
- S.onLevel = opts.onLevel;
1134
- S.onRelease = opts.onRelease;
1135
- S.texts = opts.texts || S.texts;
1136
- S.reduce = !!opts.reduce;
1137
- S.count = Math.max(1, (opts.count || 1) | 0);
1138
- S.mounted = true;
1139
- S.time = 0;
1140
- S.frameNo = 0;
1141
- S.last = 0;
1142
- S.e = S.t;
1143
- S.topFired = false;
1144
- S.warp = 0;
1145
- S.waves.length = 0;
1146
- S.debris.length = 0;
1147
- S.streaks.length = 0;
1148
- S.astra.length = 0;
1149
- S.parts = [];
1150
- S.prevParts = [];
1151
- S.partKey = "";
1152
- S.partKind = "";
1153
- S.partFade = 1;
1154
- S.prevFade = 0;
1155
- initStars();
1156
- if (!S.neb) S.neb = { a: nebulaSprite(216, 262), b: nebulaSprite(286, 196), c: nebulaSprite(190, 320) };
1157
- fit();
1158
- requestAnimationFrame(() => {
1159
- if (S.mounted) fit();
1160
- });
1161
- window.addEventListener("resize", fit);
1162
- window.addEventListener("orientationchange", fit);
1163
- /* The popup grows and shrinks as the model list opens and closes. */
1164
- if (typeof ResizeObserver === "function" && S.els && S.els.stage) {
1165
- S.ro = new ResizeObserver(() => {
1166
- if (S.mounted) fit();
1167
- });
1168
- try {
1169
- S.ro.observe(S.els.stage);
1170
- } catch (err) {
1171
- S.ro = null;
1172
- }
1173
- }
1174
- window.addEventListener("pointermove", onDocMove);
1175
- window.addEventListener("pointerup", onDocUp);
1176
- window.addEventListener("pointercancel", onDocUp);
1177
- window.addEventListener("keydown", onKey);
1178
- S.raf = requestAnimationFrame(loop);
1179
- }
1180
-
1181
- function unmount() {
1182
- S.mounted = false;
1183
- if (S.raf) cancelAnimationFrame(S.raf);
1184
- S.raf = 0;
1185
- clearTimeout(S.bigTimer);
1186
- clearTimeout(S.warpTimer);
1187
- window.removeEventListener("resize", fit);
1188
- window.removeEventListener("orientationchange", fit);
1189
- if (S.ro) {
1190
- try {
1191
- S.ro.disconnect();
1192
- } catch (err) {}
1193
- S.ro = null;
1194
- }
1195
- window.removeEventListener("pointermove", onDocMove);
1196
- window.removeEventListener("pointerup", onDocUp);
1197
- window.removeEventListener("pointercancel", onDocUp);
1198
- window.removeEventListener("keydown", onKey);
1199
- S.els = null;
1200
- S.bgc = null;
1201
- S.rxc = null;
1202
- S.fxc = null;
1203
- }
1204
-
1205
- function setCount(n) {
1206
- const next = Math.max(1, n | 0);
1207
- if (next === S.count) return;
1208
- S.count = next;
1209
- S.idx = Math.min(S.idx, topIndexOf(S.count));
1210
- }
1211
-
1212
- function setIndex(index, immediate) {
1213
- const top = topIndex();
1214
- const to = levelT(clamp(index, 0, top), S.count);
1215
- if (immediate) {
1216
- S.anim = null;
1217
- S.t = to;
1218
- S.e = to;
1219
- S.idx = Math.round(to * top);
1220
- /*
1221
- * Opening the panel must not replay the top-level celebration. Only a
1222
- * real move into the top level (drag, click, keyboard) fires boom();
1223
- * mounting while already at the top just arms the guard.
1224
- */
1225
- S.topFired = S.count > 1 && S.idx === top;
1226
- } else {
1227
- snapTo(to, 320);
1228
- }
1229
- }
1230
-
1231
- return {
1232
- mount,
1233
- unmount,
1234
- setCount,
1235
- setIndex,
1236
- beginDrag,
1237
- isDragging() {
1238
- return S.dragging;
1239
- },
1240
- isMounted() {
1241
- return S.mounted;
1242
- },
1243
- index() {
1244
- return S.idx;
1245
- }
1246
- };
1247
- }
1248
-
1249
- /* ------------------------------------------------------------------ *
1250
- * glyphs
1251
- * ------------------------------------------------------------------ */
1252
-
1253
- function boltGlyph(size, hue) {
1254
- return h(
1255
- "svg",
1256
- { width: size, height: size, viewBox: "0 0 24 24", "aria-hidden": "true", style: { display: "block" } },
1257
- h("path", {
1258
- d: "M13.6 2 5.2 13.1h5.3L9.9 22l8.9-11.6h-5.6L13.6 2Z",
1259
- fill: "currentColor",
1260
- opacity: 0.92
1261
- }),
1262
- h("path", {
1263
- d: "M13.6 2 5.2 13.1h5.3L9.9 22l8.9-11.6h-5.6L13.6 2Z",
1264
- fill: "none",
1265
- stroke: "hsl(" + hue + " 100% 78%)",
1266
- strokeWidth: 0.7,
1267
- opacity: 0.8
1268
- })
1269
- );
1270
- }
1271
-
1272
- function resetGlyph() {
1273
- return h(
1274
- "svg",
1275
- { width: 13, height: 13, viewBox: "0 0 24 24", "aria-hidden": "true" },
1276
- h("path", {
1277
- d: "M4 12a8 8 0 1 0 2.5-5.8",
1278
- fill: "none",
1279
- stroke: "currentColor",
1280
- strokeWidth: 2,
1281
- strokeLinecap: "round"
1282
- }),
1283
- h("path", { d: "M4 4v5h5", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round" })
1284
- );
1285
- }
1286
-
1287
- function closeGlyph() {
1288
- return h(
1289
- "svg",
1290
- { width: 13, height: 13, viewBox: "0 0 24 24", "aria-hidden": "true" },
1291
- h("path", { d: "M6 6l12 12M18 6L6 18", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round" })
1292
- );
1293
- }
1294
-
1295
- /* ------------------------------------------------------------------ *
1296
- * controller
1297
- * ------------------------------------------------------------------ */
1298
-
1299
- function findModel(catalog, sel) {
1300
- if (!catalog || !sel) return void 0;
1301
- const groups = Array.isArray(catalog.groups) ? catalog.groups : [];
1302
- for (let i = 0; i < groups.length; i++) {
1303
- const g = groups[i];
1304
- const models = g && Array.isArray(g.models) ? g.models : [];
1305
- for (let j = 0; j < models.length; j++) {
1306
- const m = models[j];
1307
- if (m && m.id === sel.model && (!sel.provider || g.id === sel.provider)) return m;
1308
- }
1309
- }
1310
- for (let i = 0; i < groups.length; i++) {
1311
- const models = groups[i] && Array.isArray(groups[i].models) ? groups[i].models : [];
1312
- for (let j = 0; j < models.length; j++) if (models[j] && models[j].id === sel.model) return models[j];
1313
- }
1314
- return void 0;
1315
- }
1316
-
1317
- /*
1318
- * Reasoning-effort ids form one shared scale even though providers declare
1319
- * different subsets of it (off/low/high/max here, off/minimal/low/medium/
1320
- * high/xhigh/max there). Ranking on that shared scale is what lets a level
1321
- * the user already picked be mapped onto a model that lacks it.
1322
- */
1323
- const EFFORT_LADDER = ["off", "none", "minimal", "low", "medium", "med", "high", "xhigh", "max", "ultra"];
1324
-
1325
- /** The effort levels one catalogue model declares, as plain objects. */
1326
- function modelEfforts(model) {
1327
- const reasoning = model && model.reasoning ? model.reasoning : void 0;
1328
- const efforts = reasoning && Array.isArray(reasoning.efforts) ? reasoning.efforts : [];
1329
- return efforts
1330
- .filter((ef) => ef && ef.id !== void 0 && ef.id !== null)
1331
- .map((ef) => ({
1332
- id: String(ef.id),
1333
- name: String(ef.name || ef.id),
1334
- description: ef.description ? String(ef.description) : ""
1335
- }));
1336
- }
1337
-
1338
- /**
1339
- * Map the effort being carried onto the effort list of the model being
1340
- * switched to.
1341
- *
1342
- * `{ id }` the carried level is supported as-is
1343
- * `{ id, from }` the model lacks it and the closest rung was swapped in
1344
- * `{ id: null, from }` the model declares no reasoning at all, so the
1345
- * request must omit `reasoningEffort` entirely (the
1346
- * Host rejects any explicit effort for such a model)
1347
- */
1348
- function resolveEffort(model, wantedId) {
1349
- const levels = modelEfforts(model);
1350
- const wanted = wantedId === void 0 || wantedId === null ? "" : String(wantedId);
1351
- if (levels.length === 0) return { id: null, from: wanted };
1352
- if (!wanted) return { id: null, from: "" };
1353
- if (levels.some((lv) => lv.id === wanted)) return { id: wanted, from: wanted };
1354
- const wantRank = EFFORT_LADDER.indexOf(wanted.toLowerCase());
1355
- if (wantRank < 0) {
1356
- /* A provider-specific name that is not on the shared scale: its
1357
- distance is unknowable, so prefer this model's own default and only
1358
- then its strongest rung. */
1359
- const fallback = model.reasoning && model.reasoning.defaultEffort ? String(model.reasoning.defaultEffort) : "";
1360
- if (fallback && levels.some((lv) => lv.id === fallback)) return { id: fallback, from: wanted };
1361
- return { id: levels[levels.length - 1].id, from: wanted };
1362
- }
1363
- let best = 0;
1364
- let bestGap = Infinity;
1365
- let bestScaled = -Infinity;
1366
- for (let i = 0; i < levels.length; i++) {
1367
- const rank = EFFORT_LADDER.indexOf(levels[i].id.toLowerCase());
1368
- /* An off-ladder rung still has to sit somewhere: spread the unknown
1369
- ones evenly across the interior of the scale, in declared order. */
1370
- const scaled = rank >= 0 ? rank : 1 + (levels.length > 1 ? (i / (levels.length - 1)) * (EFFORT_LADDER.length - 2) : 0);
1371
- const gap = Math.abs(scaled - wantRank);
1372
- /* Equal distance keeps the stronger rung — silently dropping reasoning
1373
- power is the worse of the two surprises — and it is decided on the
1374
- scale, not on declaration order, so a provider that lists its levels
1375
- the other way round still gets the strong pick. */
1376
- if (gap < bestGap - 1e-9 || (gap < bestGap + 1e-9 && scaled > bestScaled)) {
1377
- bestGap = Math.min(gap, bestGap);
1378
- bestScaled = scaled;
1379
- best = i;
1380
- }
1381
- }
1382
- return { id: levels[best].id, from: wanted };
1383
- }
1384
-
1385
- /** Display name of one effort id on a model, falling back to the id. */
1386
- function effortName(model, id) {
1387
- const hit = modelEfforts(model).filter((lv) => lv.id === id)[0];
1388
- return hit ? hit.name : String(id);
1389
- }
1390
-
1391
- class EffortController {
1392
- constructor(ctx) {
1393
- this.ctx = ctx;
1394
- this.catalog = null;
1395
- this.selection = null;
1396
- this.levels = [];
1397
- this.defaultIndex = 0;
1398
- this.sessionId = void 0;
1399
- this.loading = false;
1400
- this.commitTimer = 0;
1401
- this.pendingIndex = -1;
1402
- this.noticeTimer = 0;
1403
- /* The last effort the user actually chose, kept so a round-trip through
1404
- a model that cannot express it returns to that level, not a default. */
1405
- this.lastEffort = "";
1406
- this.token = 0;
1407
- this.bindT = null;
1408
- this.selFace = void 0;
1409
- this.selUnsub = void 0;
1410
- this.store = createStore({
1411
- open: false,
1412
- listOpen: false,
1413
- levels: [],
1414
- models: [],
1415
- index: 0,
1416
- t: 0,
1417
- busy: false,
1418
- error: null,
1419
- notice: null,
1420
- supported: true,
1421
- ready: false,
1422
- modelName: "",
1423
- modelId: "",
1424
- provider: ""
1425
- });
1426
- }
1427
-
1428
- bindLocale() {
1429
- const bind = readService(() => this.ctx.locale.bind(NS));
1430
- if (typeof bind === "function") this.bindT = bind;
1431
- }
1432
-
1433
- t(key, vars) {
1434
- let out = void 0;
1435
- if (this.bindT) {
1436
- try {
1437
- const v = this.bindT(key, vars);
1438
- if (typeof v === "string" && v.length && v !== key) out = v;
1439
- } catch (err) {
1440
- /* fall through to the bundled dictionary */
1441
- }
1442
- }
1443
- if (out === void 0) {
1444
- out = typeof zh[key] === "string" ? zh[key] : key;
1445
- if (vars) {
1446
- Object.keys(vars).forEach((k) => {
1447
- out = out.split("{" + k + "}").join(String(vars[k]));
1448
- });
1449
- }
1450
- }
1451
- return out;
1452
- }
1453
-
1454
- face() {
1455
- return {
1456
- store: this.store,
1457
- t: (k, v) => this.t(k, v),
1458
- open: () => this.open(),
1459
- close: () => this.close(),
1460
- toggle: () => this.toggle(),
1461
- toggleModels: () => this.toggleModels(),
1462
- chooseModel: (provider, modelId) => this.chooseModel(provider, modelId),
1463
- reset: () => this.reset(),
1464
- commit: (i) => this.commit(i),
1465
- setIndex: (i) => this.setIndex(i),
1466
- setSession: (id) => this.setSession(id)
1467
- };
1468
- }
1469
-
1470
- dispose() {
1471
- clearTimeout(this.commitTimer);
1472
- this.dropSelectionSubscription();
1473
- }
1474
-
1475
- /* ---------------- state ---------------- */
1476
-
1477
- setIndex(index) {
1478
- const snap = this.store.getSnapshot();
1479
- const top = topIndexOf(snap.levels.length);
1480
- const i = clamp(index | 0, 0, top);
1481
- if (i === snap.index && Math.abs(snap.t - levelT(i, snap.levels.length)) < 0.001) return;
1482
- this.store.set({ index: i, t: levelT(i, snap.levels.length) });
1483
- }
1484
-
1485
- setSession(id) {
1486
- if (!id || id === this.sessionId) return;
1487
- this.sessionId = id;
1488
- /* Load the level names for the pill itself, so it shows the real effort
1489
- instead of the generic label before the panel is ever opened. */
1490
- this.refresh();
1491
- }
1492
-
1493
- /**
1494
- * The Session's durable model-selection projection — the same source the
1495
- * shipped selector reads. `projections.faceOf("modelSelection")` yields
1496
- * `{ lastUsed, next }` where `next = pending ?? lastUsed`: a
1497
- * `model/selection` event carries the user's intent immediately, and a
1498
- * `request/header` records what a request actually used. `catalog.default`
1499
- * is only the deployment default and can differ from what this Session
1500
- * runs, so it is a fallback only.
1501
- */
1502
- selectionFace() {
1503
- const sessionId = this.resolveSessionId();
1504
- if (!sessionId) return void 0;
1505
- const sessions = readService(() => this.ctx.sessions);
1506
- if (!sessions || typeof sessions.binding !== "function") return void 0;
1507
- let binding;
1508
- try {
1509
- binding = sessions.binding(sessionId);
1510
- } catch (err) {
1511
- return void 0;
1512
- }
1513
- const session = binding && binding.session;
1514
- const projections = session && session.projections;
1515
- if (!projections || typeof projections.faceOf !== "function") return void 0;
1516
- try {
1517
- return projections.faceOf("modelSelection");
1518
- } catch (err) {
1519
- return void 0;
1520
- }
1521
- }
1522
-
1523
- /** Current projected selection, or undefined when the projection has no value yet. */
1524
- projectedSelection() {
1525
- const face = this.selectionFace();
1526
- if (!face || typeof face.getSnapshot !== "function") return void 0;
1527
- let view;
1528
- try {
1529
- view = face.getSnapshot();
1530
- } catch (err) {
1531
- return void 0;
1532
- }
1533
- const next = view && view.next;
1534
- if (!next || typeof next.provider !== "string" || typeof next.model !== "string") return void 0;
1535
- return next;
1536
- }
1537
-
1538
- dropSelectionSubscription() {
1539
- if (this.selUnsub) {
1540
- try {
1541
- this.selUnsub();
1542
- } catch (err) {
1543
- /* an already-disposed face throws on unsubscribe */
1544
- }
1545
- }
1546
- this.selUnsub = void 0;
1547
- this.selFace = void 0;
1548
- }
1549
-
1550
- /**
1551
- * Follow the Session's projection so a selection made anywhere else (the
1552
- * `/model` command, a restored Session) updates this control instead of
1553
- * leaving a stale label in the composer.
1554
- */
1555
- bindSelection() {
1556
- const face = this.selectionFace();
1557
- if (face === this.selFace) return;
1558
- this.dropSelectionSubscription();
1559
- if (!face || typeof face.subscribe !== "function") return;
1560
- this.selFace = face;
1561
- this.selUnsub = face.subscribe(() => {
1562
- /* Opening the face and later commits both notify; defer so the
1563
- in-flight refresh guard has cleared before this one starts. */
1564
- setTimeout(() => {
1565
- this.refresh();
1566
- }, 0);
1567
- });
1568
- }
1569
-
1570
- resolveSessionId() {
1571
- if (this.sessionId) return this.sessionId;
1572
- const sessions = readService(() => this.ctx.sessions);
1573
- if (!sessions || !sessions.list || typeof sessions.list.getSnapshot !== "function") return void 0;
1574
- let snap = void 0;
1575
- try {
1576
- snap = sessions.list.getSnapshot();
1577
- } catch (err) {
1578
- return void 0;
1579
- }
1580
- return mainViewSessionId(snap && snap.byId);
1581
- }
1582
-
1583
- open() {
1584
- this.store.set({ open: true });
1585
- this.refresh();
1586
- }
1587
-
1588
- close() {
1589
- this.store.set({ open: false, listOpen: false });
1590
- }
1591
-
1592
- toggle() {
1593
- if (this.store.getSnapshot().open) this.close();
1594
- else this.open();
1595
- }
1596
-
1597
- toggleModels() {
1598
- const snap = this.store.getSnapshot();
1599
- this.store.set({ listOpen: !snap.listOpen });
1600
- }
1601
-
1602
- reset() {
1603
- const target = this.defaultIndex;
1604
- this.setIndex(target);
1605
- this.commit(target);
1606
- }
1607
-
1608
- /**
1609
- * Switch the Session-local model while keeping the reasoning effort the
1610
- * user already picked, then reload the catalogue because the new model
1611
- * exposes its own effort list.
1612
- */
1613
- async chooseModel(provider, modelId) {
1614
- const snap = this.store.getSnapshot();
1615
- if (snap.provider === provider && snap.modelId === modelId) {
1616
- this.store.set({ listOpen: false });
1617
- return;
1618
- }
1619
- const remote = readService(() => this.ctx.remote.session);
1620
- if (!remote || typeof remote.selectModel !== "function") {
1621
- this.store.set({ error: "selectModel is unavailable in this build" });
1622
- return;
1623
- }
1624
- const sessionId = this.resolveSessionId();
1625
- if (!sessionId) {
1626
- this.store.set({ error: this.t("noSession") });
1627
- return;
1628
- }
1629
- const level = this.levels[snap.index];
1630
- if (level) this.lastEffort = level.id;
1631
- const target = findModel(this.catalog, { provider: String(provider), model: String(modelId) });
1632
- /* The Host rejects an explicit effort the target model does not declare
1633
- (UNSUPPORTED_REASONING_EFFORT) and performs no clamping of its own, so
1634
- the carried level is mapped onto the target's rungs before asking.
1635
- An unknown target keeps the old shape and lets the Host be the
1636
- authority. */
1637
- const mapped = target ? resolveEffort(target, level ? level.id : this.lastEffort) : { id: level ? level.id : null, from: "" };
1638
- const request = { sessionId, provider: String(provider), model: String(modelId) };
1639
- if (mapped.id) request.reasoningEffort = mapped.id;
1640
- const token = ++this.token;
1641
- this.store.set({ busy: true, error: null, notice: null, listOpen: false });
1642
- try {
1643
- const res = await withTimeout(remote.selectModel(request), SELECT_TIMEOUT_MS);
1644
- if (token !== this.token) return;
1645
- if (res && res.ok) {
1646
- const selected = res.value && res.value.selected;
1647
- const next = Object.assign({}, this.selection || {}, selected || {}, {
1648
- provider: request.provider,
1649
- model: request.model
1650
- });
1651
- if (mapped.id) next.reasoningEffort = mapped.id;
1652
- else delete next.reasoningEffort;
1653
- this.selection = next;
1654
- this.store.set({ busy: false, error: null, notice: this.note(this.switchNotice(target, level, mapped)) });
1655
- this.loading = false;
1656
- this.refresh();
1657
- } else {
1658
- this.store.set({ busy: false, error: messageOf(res && res.error) || "refused" });
1659
- }
1660
- } catch (err) {
1661
- if (token !== this.token) return;
1662
- this.store.set({ busy: false, error: messageOf(err) });
1663
- }
1664
- }
1665
-
1666
- /**
1667
- * One short line saying what the switch had to change, or null when the
1668
- * carried effort was accepted verbatim.
1669
- */
1670
- switchNotice(target, level, mapped) {
1671
- if (!target || !level) return null;
1672
- if (mapped.id === null) return this.t("noEffortModel", { model: target.name || target.id || "" });
1673
- if (mapped.id !== level.id) return this.t("clamped", { from: level.name || level.id, to: effortName(target, mapped.id) });
1674
- return null;
1675
- }
1676
-
1677
- /** Show a transient footer line; it expires on its own and never sticks. */
1678
- note(text) {
1679
- if (!text) return null;
1680
- clearTimeout(this.noticeTimer);
1681
- const shown = text;
1682
- this.noticeTimer = setTimeout(() => {
1683
- if (this.store.getSnapshot().notice === shown) this.store.set({ notice: null });
1684
- }, 9000);
1685
- return shown;
1686
- }
1687
-
1688
- /* ---------------- catalogue ---------------- */
1689
-
1690
- async refresh() {
1691
- if (this.loading) return;
1692
- this.loading = true;
1693
- /* Reading a projection before subscribing to it can return no value at
1694
- all, so the follow-up starts the subscription first. */
1695
- this.bindSelection();
1696
- let remote;
1697
- try {
1698
- remote = this.ctx.remote.session;
1699
- } catch (err) {
1700
- this.loading = false;
1701
- this.store.set({ supported: false, ready: true, levels: [], modelName: "", error: null, debug: "inject:" + messageOf(err) });
1702
- return;
1703
- }
1704
- if (!remote || typeof remote.modelCatalog !== "function") {
1705
- this.loading = false;
1706
- this.store.set({
1707
- supported: false,
1708
- ready: true,
1709
- levels: [],
1710
- modelName: "",
1711
- error: null,
1712
- debug: "shape:" + (remote ? Object.keys(remote).slice(0, 8).join("+") : "undef")
1713
- });
1714
- return;
1715
- }
1716
- try {
1717
- const res = await withTimeout(remote.modelCatalog(), SELECT_TIMEOUT_MS);
1718
- if (!res || !res.ok) {
1719
- this.store.set({ ready: true, error: messageOf(res && res.error) || "modelCatalog failed", debug: "res:" + (res ? String(res.ok) : "null") });
1720
- return;
1721
- }
1722
- const catalog = res.value || {};
1723
- this.catalog = catalog;
1724
- const fallback = catalog.default || {};
1725
- /* The durable projection is authoritative: it carries the user's
1726
- pending intent immediately after a `model/selection` event and the
1727
- last actually-used configuration after a `request/header`. */
1728
- const projected = this.projectedSelection();
1729
- const sel = Object.assign({}, fallback, projected || {});
1730
- this.selection = Object.assign({}, sel);
1731
- const model = findModel(catalog, sel) || findModel(catalog, fallback);
1732
- const reasoning = model && model.reasoning ? model.reasoning : void 0;
1733
- const efforts = reasoning && Array.isArray(reasoning.efforts) ? reasoning.efforts : [];
1734
- const levels = efforts
1735
- .filter((ef) => ef && ef.id !== void 0 && ef.id !== null)
1736
- .map((ef) => ({
1737
- id: String(ef.id),
1738
- name: String(ef.name || ef.id),
1739
- description: ef.description ? String(ef.description) : ""
1740
- }));
1741
- const fallbackId = reasoning && reasoning.defaultEffort ? String(reasoning.defaultEffort) : "";
1742
- let index = levels.findIndex((l) => l.id === String(sel.reasoningEffort || ""));
1743
- if (index < 0 && fallbackId) index = levels.findIndex((l) => l.id === fallbackId);
1744
- if (index < 0) index = Math.max(0, levels.length - 1);
1745
- this.levels = levels;
1746
- this.defaultIndex = index;
1747
- const groups = Array.isArray(catalog.groups) ? catalog.groups : [];
1748
- const models = groups.map((group) => ({
1749
- id: String(group && group.id != null ? group.id : ""),
1750
- name: String((group && (group.name || group.id)) || ""),
1751
- models: (Array.isArray(group && group.models) ? group.models : [])
1752
- .filter((m) => m && m.id != null)
1753
- .map((m) => ({
1754
- id: String(m.id),
1755
- name: String(m.name || m.id),
1756
- description: m.description ? String(m.description) : "",
1757
- efforts: m.reasoning && Array.isArray(m.reasoning.efforts) ? m.reasoning.efforts.length : 0
1758
- }))
1759
- }));
1760
- this.store.set({
1761
- levels,
1762
- models,
1763
- modelId: String(sel.model || ""),
1764
- provider: String(sel.provider || ""),
1765
- index,
1766
- t: levelT(index, levels.length),
1767
- supported: levels.length > 0,
1768
- ready: true,
1769
- error: null,
1770
- debug: "ok:" + levels.length,
1771
- modelName: model ? String(model.name || model.id || "") : String(sel.model || "")
1772
- });
1773
- } catch (err) {
1774
- this.store.set({ ready: true, error: messageOf(err), debug: "throw:" + messageOf(err) });
1775
- } finally {
1776
- this.loading = false;
1777
- }
1778
- }
1779
-
1780
- /* ---------------- commit ---------------- */
1781
-
1782
- commit(index) {
1783
- this.setIndex(index);
1784
- this.pendingIndex = index;
1785
- clearTimeout(this.commitTimer);
1786
- this.commitTimer = setTimeout(() => {
1787
- this.flush();
1788
- }, COMMIT_DEBOUNCE_MS);
1789
- }
1790
-
1791
- async flush() {
1792
- const index = this.pendingIndex;
1793
- this.pendingIndex = -1;
1794
- if (index < 0) return;
1795
- const level = this.levels[index];
1796
- if (!level) return;
1797
- const remote = readService(() => this.ctx.remote.session);
1798
- if (!remote || typeof remote.selectModel !== "function") {
1799
- this.store.set({ error: "selectModel is unavailable in this build" });
1800
- return;
1801
- }
1802
- const sessionId = this.resolveSessionId();
1803
- if (!sessionId) {
1804
- this.store.set({ error: this.t("noSession") });
1805
- return;
1806
- }
1807
- const base = this.selection || {};
1808
- const currentModel = this.catalog ? findModel(this.catalog, base) : void 0;
1809
- /* A drag is normally inside the current model's own rungs; resolving
1810
- anyway keeps a stale level (switch still in flight) from being sent
1811
- as an explicit unsupported effort. */
1812
- const mapped = currentModel ? resolveEffort(currentModel, level.id) : { id: level.id, from: level.id };
1813
- /* Send the explicit triple the shipped selector sends, so the Host never
1814
- has to infer provider/model from a partial request. */
1815
- const request = {
1816
- sessionId,
1817
- provider: String(base.provider || ""),
1818
- model: String(base.model || ""),
1819
- ...(mapped.id ? { reasoningEffort: mapped.id } : {})
1820
- };
1821
- const token = ++this.token;
1822
- this.store.set({ busy: true, error: null, notice: null });
1823
- try {
1824
- const res = await withTimeout(remote.selectModel(request), SELECT_TIMEOUT_MS);
1825
- if (token !== this.token) return;
1826
- if (res && res.ok) {
1827
- const selected = res.value && res.value.selected;
1828
- const applied = Object.assign({}, base, selected || {});
1829
- if (mapped.id) applied.reasoningEffort = mapped.id;
1830
- else delete applied.reasoningEffort;
1831
- if (mapped.id) this.lastEffort = mapped.id;
1832
- this.selection = Object.assign({}, applied);
1833
- /* The Host appends a `model/selection` event, so the projection now
1834
- reports this choice; re-read it to confirm and pick up names. */
1835
- this.store.set({
1836
- busy: false,
1837
- error: null,
1838
- modelId: String(applied.model || ""),
1839
- provider: String(applied.provider || "")
1840
- });
1841
- this.refresh();
1842
- } else {
1843
- this.store.set({ busy: false, error: messageOf(res && res.error) || "refused" });
1844
- }
1845
- } catch (err) {
1846
- if (token !== this.token) return;
1847
- this.store.set({ busy: false, error: messageOf(err) });
1848
- }
1849
- }
1850
- }
1851
-
1852
- /* ------------------------------------------------------------------ *
1853
- * components
1854
- * ------------------------------------------------------------------ */
1855
-
1856
- function chevronGlyph() {
1857
- return h(
1858
- "svg",
1859
- { width: 11, height: 11, viewBox: "0 0 24 24", "aria-hidden": "true", style: { display: "block" } },
1860
- h("path", {
1861
- d: "M6 9l6 6 6-6",
1862
- fill: "none",
1863
- stroke: "currentColor",
1864
- strokeWidth: 2.2,
1865
- strokeLinecap: "round",
1866
- strokeLinejoin: "round"
1867
- })
1868
- );
1869
- }
1870
-
1871
- /**
1872
- * The whole control lives in the composer's model cell: a trigger that
1873
- * reads "model · effort" plus a popup that carries the model list and the
1874
- * glowing effort rail.
1875
- */
1876
- function ModelEffortControl(props) {
1877
- const snap = react.useSyncExternalStore(props.store.subscribe, props.store.getSnapshot);
1878
- const sid = props.sessionId;
1879
- react.useEffect(() => {
1880
- if (sid && typeof props.setSession === "function") props.setSession(sid);
1881
- }, [sid]);
1882
-
1883
- const triggerRef = react.useRef(null);
1884
- const menuRef = react.useRef(null);
1885
- const trackRef = react.useRef(null);
1886
- const bgRef = react.useRef(null);
1887
- const railRef = react.useRef(null);
1888
- const fxRef = react.useRef(null);
1889
- const flashRef = react.useRef(null);
1890
- const bigRef = react.useRef(null);
1891
- const engineRef = react.useRef(null);
1892
- const levelsRef = react.useRef(snap.levels);
1893
- levelsRef.current = snap.levels;
1894
-
1895
- const posState = react.useState(null);
1896
- const pos = posState[0];
1897
- const setPos = posState[1];
1898
-
1899
- if (engineRef.current === null) engineRef.current = createEngine();
1900
-
1901
- /* Anchor the popup above the trigger, clamped into the viewport. */
1902
- react.useEffect(() => {
1903
- if (!snap.open) {
1904
- setPos(null);
1905
- return;
1906
- }
1907
- const place = () => {
1908
- const el = triggerRef.current;
1909
- if (!el || typeof el.getBoundingClientRect !== "function") return;
1910
- const rect = el.getBoundingClientRect();
1911
- /* A detached or hidden trigger reports an all-zero box; keep the last good spot. */
1912
- if (rect.width < 1 && rect.height < 1) return;
1913
- const vw = window.innerWidth || 1024;
1914
- const vh = window.innerHeight || 768;
1915
- const width = Math.min(400, Math.max(240, vw - 24));
1916
- /* Right-align the popup to the trigger, the way a composer menu reads. */
1917
- const left = Math.max(8, Math.min(rect.right - width, vw - width - 8));
1918
- /*
1919
- * Open upward by default (the trigger sits in the composer, near the
1920
- * bottom). On a short viewport — a landscape phone, a small window —
1921
- * there may be almost no room above, so fall back to opening downward
1922
- * and cap the height to the space that is actually available. Without
1923
- * this the popup ran off the top of the screen.
1924
- */
1925
- const gap = 8;
1926
- const spaceAbove = rect.top - gap - 8;
1927
- const spaceBelow = vh - rect.bottom - gap - 8;
1928
- const up = spaceAbove >= 220 || spaceAbove >= spaceBelow;
1929
- const room = Math.max(140, up ? spaceAbove : spaceBelow);
1930
- setPos(
1931
- up
1932
- ? { left, width, bottom: vh - rect.top + gap, maxHeight: room, top: null }
1933
- : { left, width, top: rect.bottom + gap, maxHeight: room, bottom: null }
1934
- );
1935
- };
1936
- place();
1937
- window.addEventListener("resize", place);
1938
- window.addEventListener("scroll", place, true);
1939
- return () => {
1940
- window.removeEventListener("resize", place);
1941
- window.removeEventListener("scroll", place, true);
1942
- };
1943
- }, [snap.open]);
1944
-
1945
- const ready = snap.open && pos !== null;
1946
-
1947
- react.useEffect(() => {
1948
- if (!ready) return;
1949
- const engine = engineRef.current;
1950
- if (!engine) return;
1951
- const reduce =
1952
- typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
1953
- engine.mount({
1954
- els: {
1955
- stage: menuRef.current,
1956
- track: trackRef.current,
1957
- bg: bgRef.current,
1958
- rail: railRef.current,
1959
- fx: fxRef.current,
1960
- flash: flashRef.current,
1961
- big: bigRef.current
1962
- },
1963
- count: levelsRef.current.length,
1964
- texts: { boom: props.t("boom"), after: props.t("afterBoom") },
1965
- reduce,
1966
- onLevel: (i) => props.setIndex(i),
1967
- onRelease: (i) => props.commit(i)
1968
- });
1969
- engine.setIndex(snap.index, true);
1970
- return () => engine.unmount();
1971
- }, [ready]);
1972
-
1973
- react.useEffect(() => {
1974
- const engine = engineRef.current;
1975
- if (!engine || !ready || !engine.isMounted()) return;
1976
- engine.setCount(snap.levels.length);
1977
- if (!engine.isDragging()) engine.setIndex(snap.index, true);
1978
- }, [snap.levels.length]);
1979
-
1980
- /* Dismiss on an outside press or Escape. */
1981
- react.useEffect(() => {
1982
- if (!snap.open) return;
1983
- const onDown = (ev) => {
1984
- const target = ev.target;
1985
- const menu = menuRef.current;
1986
- const trigger = triggerRef.current;
1987
- if (menu && typeof menu.contains === "function" && menu.contains(target)) return;
1988
- if (trigger && typeof trigger.contains === "function" && trigger.contains(target)) return;
1989
- props.close();
1990
- };
1991
- const onEsc = (ev) => {
1992
- if (ev.key === "Escape") props.close();
1993
- };
1994
- document.addEventListener("pointerdown", onDown, true);
1995
- document.addEventListener("keydown", onEsc, true);
1996
- return () => {
1997
- document.removeEventListener("pointerdown", onDown, true);
1998
- document.removeEventListener("keydown", onEsc, true);
1999
- };
2000
- }, [snap.open]);
2001
-
2002
- const hue = Math.round(hueAtT(snap.t));
2003
- const current = snap.levels[snap.index];
2004
- const n = snap.levels.length;
2005
- const modelText = snap.modelId || snap.modelName || "";
2006
- const effortText = current ? current.name : "";
2007
- const locked = props.locked === true;
2008
- const summary = props.t("triggerHint") + (modelText ? " — " + modelText : "") + (effortText ? " · " + effortText : "") + (snap.error ? " · " + snap.error : "");
2009
-
2010
- const trigger = h(
2011
- "button",
2012
- {
2013
- ref: triggerRef,
2014
- key: "trigger",
2015
- type: "button",
2016
- className: "es-trigger",
2017
- title: summary,
2018
- "aria-label": summary,
2019
- "aria-haspopup": "dialog",
2020
- "aria-expanded": snap.open ? "true" : "false",
2021
- "data-open": snap.open ? "1" : "0",
2022
- "data-locked": locked ? "1" : "0",
2023
- disabled: locked,
2024
- style: { "--es-hue": String(hue), "--es-e": snap.t.toFixed(3) },
2025
- onClick: () => props.toggle()
2026
- },
2027
- h("span", { className: "es-trigger-bolt", key: "b" }, boltGlyph(14, hue)),
2028
- h("span", { className: "es-trigger-text", key: "m" }, safeLabel(modelText) || props.t("modelLabel")),
2029
- effortText ? h("span", { className: "es-trigger-sep", key: "s" }, "·") : null,
2030
- effortText ? h("span", { className: "es-trigger-effort", key: "e" }, safeLabel(effortText)) : null,
2031
- h("span", { className: "es-trigger-chev", key: "c" }, chevronGlyph())
2032
- );
2033
-
2034
- if (!ready) return trigger;
2035
-
2036
- const footText = snap.error
2037
- ? props.t("failed", { message: snap.error })
2038
- : snap.busy
2039
- ? props.t("switching")
2040
- : snap.notice
2041
- ? snap.notice
2042
- : !snap.supported && snap.ready
2043
- ? props.t("noReasoning")
2044
- : current && current.description
2045
- ? current.description
2046
- : props.t("hint");
2047
- const footNote = !snap.error && !snap.busy && !!snap.notice;
2048
-
2049
- const modelNodes = [];
2050
- snap.models.forEach((group) => {
2051
- if (group.name) modelNodes.push(h("div", { key: "gh-" + group.id, className: "es-models-group" }, safeLabel(group.name)));
2052
- group.models.forEach((entry) => {
2053
- const on = entry.id === snap.modelId && group.id === snap.provider;
2054
- modelNodes.push(
2055
- h(
2056
- "button",
2057
- {
2058
- key: "mi-" + group.id + "-" + entry.id,
2059
- type: "button",
2060
- className: "es-model-item",
2061
- "data-on": on ? "1" : "0",
2062
- title: entry.description || entry.name,
2063
- onClick: () => props.chooseModel(group.id, entry.id)
2064
- },
2065
- h("span", { className: "es-model-item-name", key: "n" }, safeLabel(entry.name)),
2066
- entry.efforts === 0 ? h("span", { className: "es-model-item-tag", key: "tag" }, props.t("noLevelsTag")) : null
2067
- )
2068
- );
2069
- });
2070
- });
2071
-
2072
- const menu = h(
2073
- "div",
2074
- {
2075
- ref: menuRef,
2076
- key: "menu",
2077
- className: "es-menu",
2078
- role: "dialog",
2079
- "aria-label": props.t("panelTitle"),
2080
- style: {
2081
- left: pos.left + "px",
2082
- ...(pos.top === null || pos.top === void 0 ? {} : { top: pos.top + "px" }),
2083
- ...(pos.bottom === null || pos.bottom === void 0 ? {} : { bottom: pos.bottom + "px" }),
2084
- maxHeight: pos.maxHeight + "px",
2085
- width: pos.width + "px",
2086
- "--es-hue": String(hue),
2087
- "--es-t": snap.t.toFixed(4),
2088
- "--es-e": snap.t.toFixed(3)
2089
- }
2090
- },
2091
- h("canvas", { className: "es-menu-bg", key: "bg", ref: bgRef }),
2092
- h("div", { className: "es-menu-vig", key: "vig" }),
2093
- h(
2094
- "div",
2095
- { className: "es-menu-body", key: "body" },
2096
- h(
2097
- "button",
2098
- {
2099
- className: "es-menu-row",
2100
- key: "row",
2101
- type: "button",
2102
- "data-open": snap.listOpen ? "1" : "0",
2103
- "aria-expanded": snap.listOpen ? "true" : "false",
2104
- onClick: () => props.toggleModels()
2105
- },
2106
- h("span", { className: "es-menu-row-label", key: "l" }, props.t("modelLabel")),
2107
- h("span", { className: "es-menu-row-value", key: "v", title: modelText }, safeLabel(modelText) || props.t("noModels")),
2108
- h("span", { className: "es-menu-chev", key: "c" }, chevronGlyph())
2109
- ),
2110
- snap.listOpen
2111
- ? h(
2112
- "div",
2113
- { className: "es-models", key: "models" },
2114
- modelNodes.length
2115
- ? modelNodes
2116
- : h("div", { className: "es-models-group", key: "none" }, props.t("noModels"))
2117
- )
2118
- : null,
2119
- h("div", { className: "es-menu-sep", key: "sep" }),
2120
- h(
2121
- "div",
2122
- { className: "es-effort-head", key: "eh" },
2123
- h("span", { className: "es-head-glyph", key: "g" }, boltGlyph(13, hue)),
2124
- h("span", { key: "t" }, props.t("effortLabel")),
2125
- current ? h("span", { className: "es-tag", key: "tag" }, current.name) : null
2126
- ),
2127
- h(
2128
- "div",
2129
- { className: "es-track", key: "track", ref: trackRef, onPointerDown: (ev) => engineRef.current.beginDrag(ev) },
2130
- h("canvas", { className: "es-rail", key: "rail", ref: railRef }),
2131
- h(
2132
- "div",
2133
- { className: "es-ticks", key: "ticks" },
2134
- snap.levels.map((lv, i) =>
2135
- h("span", {
2136
- key: "tick-" + lv.id,
2137
- className: "es-tick",
2138
- "data-on": i <= snap.index ? "1" : "0",
2139
- style: { "--i": n > 1 ? (i / (n - 1)).toFixed(4) : "0" }
2140
- })
2141
- )
2142
- ),
2143
- h("div", { className: "es-thumb", key: "thumb" })
2144
- ),
2145
- h(
2146
- "div",
2147
- { className: "es-scale", key: "scale" },
2148
- snap.levels.map((lv, i) =>
2149
- h(
2150
- "button",
2151
- {
2152
- key: "sc-" + lv.id,
2153
- type: "button",
2154
- className: "es-scale-item",
2155
- "data-on": i === snap.index ? "1" : "0",
2156
- style: { left: "calc(31px + (100% - 62px) * " + (n > 1 ? (i / (n - 1)).toFixed(4) : "0") + ")" },
2157
- title: lv.description || lv.name,
2158
- onClick: () => props.commit(i)
2159
- },
2160
- lv.name
2161
- )
2162
- )
2163
- ),
2164
- h("div", { className: "es-foot", key: "foot", "data-err": snap.error ? "1" : "0", "data-note": footNote ? "1" : "0" }, footText)
2165
- ),
2166
- h("canvas", { className: "es-fx", key: "fx", ref: fxRef }),
2167
- h("div", { className: "es-flash", key: "flash", ref: flashRef }),
2168
- h("div", { className: "es-big", key: "big", ref: bigRef })
2169
- );
2170
-
2171
- /* The popup is portalled to <body>: the composer row can sit inside a
2172
- transformed or overflow-clipped ancestor, which would otherwise move
2173
- or cut a position:fixed child. */
2174
- const portal =
2175
- reactDom && typeof reactDom.createPortal === "function" && typeof document !== "undefined"
2176
- ? reactDom.createPortal(menu, document.body)
2177
- : menu;
2178
-
2179
- return h("span", { className: "es-anchor", key: "anchor" }, trigger, portal);
2180
- }
2181
-
2182
- /* ------------------------------------------------------------------ *
2183
- * module surface
2184
- * ------------------------------------------------------------------ */
2185
-
2186
- /**
2187
- * BOTH `remote` and `remote.session` are required: Cordis validates the
2188
- * nested property access (`ctx.get("remote").session`) against this list, and
2189
- * a missing entry throws `cannot get property "remote.session" without
2190
- * inject` — which, from a render path, blanks the panel.
2191
- */
2192
- const inject = ["slots", "locale", "sessions", "remote", "remote.session"];
2193
-
2194
- function apply(ctx) {
2195
- injectCss();
2196
-
2197
- ctx.effect(
2198
- () => {
2199
- try {
2200
- return ctx.locale.register(NS, { zh, en });
2201
- } catch (err) {
2202
- return () => {};
2203
- }
2204
- },
2205
- "effort-slider: dictionaries"
2206
- );
2207
-
2208
- const controller = new EffortController(ctx);
2209
- controller.bindLocale();
2210
-
2211
- ctx.effect(() => () => controller.dispose(), "effort-slider: controller");
2212
-
2213
- /* Take over the shipped model cell so model + reasoning effort are one
2214
- control. A single slot renders the LOWEST priority first
2215
- (`entries` is sorted ascending by priority, and the renderer takes
2216
- `entriesOfSlot(key)[0]`), so the shipped occupant (priority 0) is
2217
- shadowed only by a negative priority. */
2218
- ctx.slots.inject("conversation.input.model", () => {
2219
- try {
2220
- const unregister = ctx.slots.register(
2221
- {
2222
- name: "conversation.input.model",
2223
- priority: -1,
2224
- label: () => "Model and reasoning effort",
2225
- inject: () => controller.face()
2226
- },
2227
- ModelEffortControl
2228
- );
2229
- return () => {
2230
- unregister();
2231
- };
2232
- } catch (err) {
2233
- return () => {};
2234
- }
2235
- });
2236
- }
2237
-
2238
- exports.name = "@lolannn/dsh-effort-slider";
2239
- exports.inject = inject;
2240
- exports.apply = apply;
2241
-
2242
- return module.exports;
2243
- }
2244
- });
1
+ /**
2
+ * dsh-effort-slider — browser half.
3
+ *
4
+ * Two registrations:
5
+ * conversation.input.right : a compact pill that shows the session's current
6
+ * reasoning effort and opens the panel.
7
+ * shell.overlay : the frame-wide panel, with a starfield, a live
8
+ * rail whose particles/ribbons/stars/galaxy scale
9
+ * with the chosen level, and light-blade bursts.
10
+ *
11
+ * The levels are read at runtime from the session model catalogue, so the
12
+ * slider always matches the efforts the current model actually declares.
13
+ */
14
+ window.__ModuleLoader__.load({
15
+ id: "@lolannn/dsh-effort-slider",
16
+ factory: (require) => {
17
+ var module = { exports: {} };
18
+ var exports = module.exports;
19
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
20
+
21
+ const react = require("react");
22
+ const reactDom = require("react-dom");
23
+ const h = react.createElement;
24
+
25
+ const NS = "effort-slider";
26
+ const CSS_TAG = "dsh-effort-slider/client";
27
+ const EDGE = 31;
28
+ const SELECT_TIMEOUT_MS = 15000;
29
+ const COMMIT_DEBOUNCE_MS = 260;
30
+
31
+ /* ------------------------------------------------------------------ *
32
+ * dictionaries
33
+ * ------------------------------------------------------------------ */
34
+
35
+ const zh = {
36
+ pill: "思考强度",
37
+ pillHint: "模型与思考强度",
38
+ panelTitle: "思考强度",
39
+ modelLabel: "模型",
40
+ effortLabel: "思考强度",
41
+ noModels: "没有可用的模型",
42
+ triggerHint: "选择模型与思考强度",
43
+ hint: "拖动滑块,或用 ← / → / Home / End 切换档位",
44
+ reset: "回到默认档位",
45
+ close: "关闭",
46
+ switching: "正在切换…",
47
+ failed: "切换失败:{message}",
48
+ noSession: "当前没有活动会话",
49
+ noReasoning: "当前模型没有可调的思考强度档位",
50
+ clamped: "该模型没有「{from}」档,已自动切到最接近的「{to}」",
51
+ noEffortModel: "「{model}」没有思考强度档位,已按模型默认运行",
52
+ noLevelsTag: "无档位",
53
+ haloLabel: "外圈光环",
54
+ haloTitle: "在输入框外面加一圈随档位变化的光环;关闭后只在档位按钮周围发光",
55
+ boom: "宇宙毁灭",
56
+ afterBoom: "星际探索"
57
+ };
58
+
59
+ const en = {
60
+ pill: "Effort",
61
+ pillHint: "Model and reasoning effort",
62
+ panelTitle: "Reasoning effort",
63
+ modelLabel: "Model",
64
+ effortLabel: "Reasoning effort",
65
+ noModels: "No model is available",
66
+ triggerHint: "Choose model and reasoning effort",
67
+ hint: "Drag the rail, or press ← / → / Home / End",
68
+ reset: "Back to the default level",
69
+ close: "Close",
70
+ switching: "Switching…",
71
+ failed: "Switch failed: {message}",
72
+ noSession: "No active session",
73
+ noReasoning: "This model exposes no reasoning-effort levels",
74
+ clamped: "This model has no \"{from}\" level — switched to the nearest one, \"{to}\"",
75
+ noEffortModel: "\"{model}\" exposes no effort levels — running at its default",
76
+ noLevelsTag: "no levels",
77
+ haloLabel: "Composer halo",
78
+ haloTitle: "Ring the input box with a glow that follows the effort level; off keeps the glow on the pill only",
79
+ boom: "SINGULARITY",
80
+ afterBoom: "DEEP SPACE"
81
+ };
82
+
83
+ /* ------------------------------------------------------------------ *
84
+ * stylesheet
85
+ * ------------------------------------------------------------------ */
86
+
87
+ const CSS = [
88
+ ".es-anchor{display:inline-flex;align-items:center;min-width:0;max-width:100%}",
89
+ ".es-trigger{--es-hue:214;--es-e:0;position:relative;display:inline-flex;align-items:center;gap:6px;height:28px;max-width:min(340px,44vw);padding:0 9px;margin:0;border-radius:999px;border:1px solid transparent;background:transparent;color:#c3cbdb;font:inherit;font-size:12.5px;font-weight:500;line-height:1;cursor:pointer;white-space:nowrap;transition:background .2s ease,border-color .25s ease,color .2s ease,box-shadow .35s ease}",
90
+ ".es-trigger:hover{background:rgba(255,255,255,.055);border-color:rgba(255,255,255,.1);color:#eef2fb}",
91
+ '.es-trigger[data-open="1"]{background:rgba(255,255,255,.09);border-color:hsl(var(--es-hue) 100% 76% / .34);color:#fff}',
92
+ '.es-trigger[data-locked="1"]{opacity:.5;cursor:default}',
93
+ ".es-halo{position:fixed;z-index:2147482600;pointer-events:none;opacity:0;border-radius:20px;border:1px solid hsl(var(--es-hue) 100% 74% / var(--es-a0,.12));box-shadow:0 0 0 1px hsl(var(--es-hue) 100% 74% / var(--es-a1,.05)),0 0 var(--es-b1,10px) hsl(var(--es-hue) 100% 64% / var(--es-a2,.1)),0 0 var(--es-b2,34px) hsl(var(--es-hue) 100% 58% / var(--es-a3,.05));transition:opacity .5s ease,box-shadow .45s ease,border-color .45s ease;will-change:opacity,box-shadow}",
94
+ '.es-halo[data-on="1"]{opacity:1}',
95
+ '.es-halo[data-hot="1"]{animation:es-halo-breathe 3.6s ease-in-out infinite}',
96
+ "@keyframes es-halo-breathe{0%,100%{opacity:.62}50%{opacity:1}}",
97
+ ".es-trigger-bolt{display:var(--dsh-composer-model-icon-display,none);width:14px;height:14px;flex:none;align-items:center;justify-content:center;color:hsl(var(--es-hue) 100% 78%)}",
98
+ ".es-trigger-text{display:var(--dsh-composer-model-text-display,inline);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:180px}",
99
+ ".es-trigger-effort{display:var(--dsh-composer-model-text-display,inline);font-weight:600;color:hsl(var(--es-hue) 100% 84%);text-shadow:0 0 10px hsl(var(--es-hue) 100% 70% / .45)}",
100
+ ".es-trigger-sep{display:var(--dsh-composer-model-text-display,inline);opacity:.38;margin:0 1px}",
101
+ ".es-trigger-chev{display:inline-flex;width:10px;height:10px;flex:none;opacity:.55;margin-left:1px}",
102
+
103
+ ".es-menu{--es-hue:214;--es-t:0;--es-e:0;position:fixed;z-index:2147483000;display:flex;flex-direction:column;width:min(400px,calc(100vw - 24px));max-height:calc(100vh - 24px);border-radius:20px;background:rgba(11,14,21,.93);border:1px solid rgba(255,255,255,.1);backdrop-filter:blur(24px) saturate(150%);-webkit-backdrop-filter:blur(24px) saturate(150%);box-shadow:0 26px 70px rgba(0,0,0,.62),inset 0 1px 0 rgba(255,255,255,.06);overflow:hidden;color:#e8ecf7;isolation:isolate;animation:es-pop-menu .2s cubic-bezier(.2,1.1,.3,1) both}",
104
+ ".es-menu.es-quake{animation:es-quake-menu .52s cubic-bezier(.36,.07,.19,.97) both}",
105
+ ".es-menu-bg{position:absolute;inset:0;width:100%;height:100%;display:block;pointer-events:none;opacity:.9}",
106
+ ".es-menu-vig{position:absolute;inset:0;pointer-events:none;background:radial-gradient(120% 100% at 50% 40%,transparent 42%,rgba(0,0,0,.55) 100%)}",
107
+ ".es-menu-body{position:relative;display:flex;flex-direction:column;flex:1 1 auto;min-height:0;overflow:hidden;padding:9px 11px 11px}",
108
+ ".es-menu-row{display:flex;align-items:center;gap:8px;width:100%;padding:8px 10px;border:0;border-radius:12px;background:transparent;color:#e8ecf7;font:inherit;font-size:13px;cursor:pointer;text-align:left;transition:background .18s ease}",
109
+ ".es-menu-row:hover{background:rgba(255,255,255,.06)}",
110
+ ".es-menu-row-label{flex:none;color:#8b93a6;font-size:12px}",
111
+ ".es-menu-row-value{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600;color:#dfe6f5}",
112
+ ".es-menu-chev{display:inline-flex;width:11px;height:11px;flex:none;opacity:.55;transition:transform .22s ease}",
113
+ '.es-menu-row[data-open="1"] .es-menu-chev{transform:rotate(180deg)}',
114
+ ".es-models{display:flex;flex-direction:column;gap:1px;flex:0 1 auto;min-height:0;max-height:216px;overflow:auto;margin:2px 0 2px;padding:4px 5px 5px;border-radius:12px;background:rgba(6,8,13,.66);box-shadow:inset 0 1px 0 rgba(255,255,255,.05);scrollbar-width:thin;overscroll-behavior:contain}",
115
+ ".es-models-group{padding:7px 6px 3px;font-size:10.5px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:#8d96aa}",
116
+ ".es-model-item{display:flex;align-items:center;gap:8px;padding:7px 10px;border:0;border-radius:9px;background:transparent;color:#ccd5e6;font:inherit;font-size:12.5px;cursor:pointer;text-align:left;transition:background .16s ease,color .16s ease}",
117
+ ".es-model-item:hover{background:rgba(255,255,255,.07);color:#fff}",
118
+ '.es-model-item[data-on="1"]{color:#fff;background:hsl(var(--es-hue) 100% 62% / .2);box-shadow:inset 2px 0 0 hsl(var(--es-hue) 100% 72%)}',
119
+ ".es-model-item-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
120
+ ".es-model-item-tag{flex:none;font-size:10px;color:#7f8798}",
121
+ ".es-menu-sep{height:1px;margin:6px 2px 0;background:linear-gradient(90deg,transparent,rgba(255,255,255,.14),transparent)}",
122
+ ".es-effort-head{display:flex;align-items:center;gap:7px;padding:9px 10px 7px;font-size:11px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:#8b93a6}",
123
+ ".es-effort-head .es-head-glyph{display:inline-flex;width:13px;height:13px;flex:none}",
124
+ ".es-effort-head .es-tag{margin-left:auto;text-transform:none}",
125
+ ".es-tag{font-size:11px;font-weight:700;letter-spacing:.06em;padding:2px 7px;border-radius:999px;border:1px solid hsl(var(--es-hue) 100% 72% / .38);color:hsl(var(--es-hue) 100% 84%);background:hsl(var(--es-hue) 100% 62% / .12);white-space:nowrap}",
126
+ ".es-menu .es-track{height:52px;margin:0 2px}",
127
+ ".es-menu .es-scale{margin:7px 2px 0}",
128
+ ".es-menu .es-foot{padding:8px 2px 0}",
129
+ ".es-menu .es-big{top:44%;font-size:clamp(19px,6vw,30px);letter-spacing:.12em}",
130
+ ".es-track{position:relative;height:62px;border-radius:999px;touch-action:none;cursor:pointer;user-select:none;-webkit-user-select:none;background:radial-gradient(120% 180% at 50% 50%,rgba(255,255,255,.045),rgba(255,255,255,.012) 60%,rgba(0,0,0,.25));border:1px solid rgba(255,255,255,.07);box-shadow:inset 0 2px 14px rgba(0,0,0,.7),0 0 0 1px rgba(0,0,0,.35)}",
131
+ ".es-rail{position:absolute;inset:0;width:100%;height:100%;border-radius:999px;display:block;pointer-events:none}",
132
+ ".es-thumb{position:absolute;top:50%;left:calc(31px + (100% - 62px) * var(--es-t,0));width:40px;height:40px;border-radius:50%;transform:translate(-50%,-50%);background:radial-gradient(circle at 38% 34%,#fff 0%,#fff 42%,#dbe6ff 62%,#9fb6ff 100%);box-shadow:0 0 18px rgba(255,255,255,.95),0 0 46px hsl(var(--es-hue) 100% 72% / .85),0 0 90px hsl(var(--es-hue) 100% 62% / .55),inset 0 -3px 10px rgba(120,150,255,.5);pointer-events:none;transition:box-shadow .35s ease}",
133
+ ".es-thumb::after{content:'';position:absolute;inset:-6px;border-radius:50%;background:radial-gradient(circle,hsl(var(--es-hue) 100% 75% / .35),transparent 68%);filter:blur(3px)}",
134
+ ".es-ticks{position:absolute;inset:0;pointer-events:none}",
135
+ ".es-tick{position:absolute;top:50%;width:3px;height:3px;border-radius:50%;transform:translate(-50%,-50%);background:rgba(255,255,255,.22);left:calc(31px + (100% - 62px) * var(--i,0))}",
136
+ '.es-tick[data-on="1"]{background:#fff;box-shadow:0 0 8px #fff,0 0 18px hsl(var(--es-hue) 100% 70% / .9)}',
137
+ ".es-scale{position:relative;height:15px;margin:9px 0 0;padding:0;font-size:11px;color:#6d7588}",
138
+ ".es-scale-item{position:absolute;top:0;transform:translateX(-50%);padding:0 5px;border:0;background:none;font:inherit;color:inherit;cursor:pointer;white-space:nowrap;transition:color .2s ease,text-shadow .3s ease}",
139
+ ".es-scale-item:hover{color:#cfd8ea}",
140
+ '.es-scale-item[data-on="1"]{color:#fff;text-shadow:0 0 12px hsl(var(--es-hue) 100% 72% / .9)}',
141
+ ".es-foot{display:flex;align-items:center;gap:8px;padding:10px 2px 0;font-size:11.5px;color:#7f8798;min-height:18px;line-height:1.4}",
142
+ '.es-foot[data-err="1"]{color:#ff9a9a}',
143
+ '.es-foot[data-note="1"]{color:hsl(var(--es-hue) 100% 84%)}',
144
+ ".es-opt{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 2px 0;font-size:11.5px;color:#8b93a6}",
145
+ ".es-switch{flex:none;width:34px;height:19px;padding:0;border-radius:999px;border:1px solid rgba(255,255,255,.14);background:rgba(255,255,255,.06);position:relative;cursor:pointer;transition:background .2s ease,border-color .2s ease}",
146
+ '.es-switch[data-on="1"]{background:hsl(var(--es-hue) 100% 62% / .38);border-color:hsl(var(--es-hue) 100% 70% / .55)}',
147
+ ".es-switch-knob{position:absolute;top:1.5px;left:1.5px;width:14px;height:14px;border-radius:50%;background:#cfd6e6;transition:transform .18s ease,background .18s ease}",
148
+ '.es-switch[data-on="1"] .es-switch-knob{transform:translateX(15px);background:#fff}',
149
+ '.es-trigger[data-halo="0"][data-open="1"]{box-shadow:0 0 14px hsl(var(--es-hue) 100% 66% / .28)}',
150
+ ".es-fx{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;mix-blend-mode:screen;display:block}",
151
+ ".es-flash{position:absolute;inset:0;pointer-events:none;opacity:0;background:radial-gradient(60% 60% at 50% 50%,#fff,rgba(190,215,255,.6) 45%,transparent 72%)}",
152
+ ".es-flash.go{animation:es-flash 1.1s ease-out both}",
153
+ ".es-big{position:absolute;left:50%;top:38%;transform:translate(-50%,-50%);pointer-events:none;opacity:0;font-size:clamp(34px,11vw,86px);font-weight:900;letter-spacing:.16em;color:#fff;white-space:nowrap;text-shadow:0 0 22px rgba(255,255,255,.85),0 0 70px hsl(var(--es-hue) 100% 65% / .9),0 0 140px hsl(var(--es-hue) 100% 60% / .6)}",
154
+ ".es-big.go{animation:es-big 3.2s cubic-bezier(.2,.7,.3,1) both}",
155
+ "@keyframes es-pop-menu{0%{opacity:0;transform:translateY(7px) scale(.97)}100%{opacity:1;transform:translateY(0) scale(1)}}",
156
+ "@keyframes es-flash{0%{opacity:0}6%{opacity:.95}22%{opacity:.55}100%{opacity:0}}",
157
+ "@keyframes es-big{0%{opacity:0;transform:translate(-50%,-50%) scale(.7);filter:blur(10px)}18%{opacity:1;transform:translate(-50%,-50%) scale(1.04);filter:blur(0)}62%{opacity:1;transform:translate(-50%,-50%) scale(1)}100%{opacity:0;transform:translate(-50%,-50%) scale(1.1);filter:blur(6px)}}",
158
+ "@keyframes es-quake-menu{0%{transform:translate(0,0) rotate(0)}12%{transform:translate(-6px,3px) rotate(-.4deg)}24%{transform:translate(5px,-4px) rotate(.36deg)}38%{transform:translate(-4px,-2px) rotate(-.24deg)}52%{transform:translate(3px,3px) rotate(.24deg)}68%{transform:translate(-2px,1px) rotate(-.14deg)}84%{transform:translate(1px,-1px) rotate(.08deg)}100%{transform:translate(0,0) rotate(0)}}",
159
+ "@media (prefers-reduced-motion:reduce){.es-menu{animation:none!important}.es-menu.es-quake{animation:none!important}.es-flash.go,.es-big.go{animation-duration:.4s}.es-thumb{transition:none}}",
160
+ "@media (max-height:660px){.es-menu .es-track{height:46px}.es-models{max-height:150px}}"
161
+ ].join("\n");
162
+
163
+ function injectCss() {
164
+ if (typeof document === "undefined") return;
165
+ const existing = document.querySelector("style[data-plugin-css=" + JSON.stringify(CSS_TAG) + "]");
166
+ if (existing !== null) return;
167
+ const tag = document.createElement("style");
168
+ tag.dataset.plugin = "@lolannn/dsh-effort-slider";
169
+ tag.dataset.pluginCss = CSS_TAG;
170
+ tag.textContent = CSS;
171
+ document.head.appendChild(tag);
172
+ }
173
+
174
+ /* ------------------------------------------------------------------ *
175
+ * small helpers
176
+ * ------------------------------------------------------------------ */
177
+
178
+ function readService(read) {
179
+ try {
180
+ return read();
181
+ } catch (err) {
182
+ return void 0;
183
+ }
184
+ }
185
+
186
+ /*
187
+ * Never let a label rendered inside a <button> look like a filesystem path.
188
+ *
189
+ * The pocket client installs a document-level click guard that inspects
190
+ * event.target.closest("button, a").textContent; when it matches
191
+ * /[\w.\-]+\/[\w.\-]+\.\w{1,12}/ (or a leading "/", "~/", "C:\") it calls
192
+ * preventDefault() + stopImmediatePropagation() and shows a "cannot open
193
+ * computer files on your phone" toast. A model identifier such as
194
+ * "workbuddy-ai/deepseek-v4.1-flash" matches that pattern, so on a phone the
195
+ * trigger silently became unclickable. The slash is display-only: the real
196
+ * identifier stays in aria-label/title and in the request we send.
197
+ */
198
+ function safeLabel(text) {
199
+ if (text === void 0 || text === null) return "";
200
+ return String(text).replace(/[\\/]+/g, " · ");
201
+ }
202
+
203
+ function clamp01(v) {
204
+ return v < 0 ? 0 : v > 1 ? 1 : v;
205
+ }
206
+
207
+ function clamp(v, lo, hi) {
208
+ return v < lo ? lo : v > hi ? hi : v;
209
+ }
210
+
211
+ function easeOutBack(p) {
212
+ const c1 = 1.15;
213
+ const c3 = c1 + 1;
214
+ const q = p - 1;
215
+ return 1 + c3 * q * q * q + c1 * q * q;
216
+ }
217
+
218
+ function messageOf(err) {
219
+ if (!err) return "";
220
+ if (typeof err === "string") return err;
221
+ if (typeof err.message === "string") return err.message;
222
+ try {
223
+ return JSON.stringify(err);
224
+ } catch (e) {
225
+ return String(err);
226
+ }
227
+ }
228
+
229
+ function withTimeout(work, ms) {
230
+ return new Promise((resolve, reject) => {
231
+ const timer = setTimeout(() => reject(new Error("timed out after " + ms + "ms")), ms);
232
+ Promise.resolve(work).then(
233
+ (value) => {
234
+ clearTimeout(timer);
235
+ resolve(value);
236
+ },
237
+ (err) => {
238
+ clearTimeout(timer);
239
+ reject(err);
240
+ }
241
+ );
242
+ });
243
+ }
244
+
245
+ function mainViewSessionId(byId) {
246
+ if (byId === void 0 || byId === null) return void 0;
247
+ const rows = Object.values(byId);
248
+ for (const row of rows) {
249
+ if (row !== void 0 && (row.retainedBy && row.retainedBy.mainView ? row.retainedBy.mainView : 0) > 0) return row.id;
250
+ }
251
+ return void 0;
252
+ }
253
+
254
+ function createStore(initial) {
255
+ let state = initial;
256
+ const listeners = new Set();
257
+ return {
258
+ getSnapshot() {
259
+ return state;
260
+ },
261
+ set(patch) {
262
+ const next = Object.assign({}, state, typeof patch === "function" ? patch(state) : patch);
263
+ state = next;
264
+ listeners.forEach((fn) => {
265
+ try {
266
+ fn();
267
+ } catch (err) {
268
+ /* a broken subscriber must not break the store */
269
+ }
270
+ });
271
+ },
272
+ subscribe(fn) {
273
+ listeners.add(fn);
274
+ return () => {
275
+ listeners.delete(fn);
276
+ };
277
+ }
278
+ };
279
+ }
280
+
281
+ /* ------------------------------------------------------------------ *
282
+ * level -> visual spec
283
+ * ------------------------------------------------------------------ */
284
+
285
+ function topIndexOf(count) {
286
+ return Math.max(0, count - 1);
287
+ }
288
+
289
+ function levelT(index, count) {
290
+ const top = topIndexOf(count);
291
+ return top > 0 ? clamp(index, 0, top) / top : 0;
292
+ }
293
+
294
+ function hueAtT(t) {
295
+ return t >= 0.92 ? 188 : 200 + 106 * t;
296
+ }
297
+
298
+ function kindAtT(t) {
299
+ if (t <= 0.001) return "none";
300
+ if (t < 0.3) return "dot";
301
+ if (t < 0.5) return "trail";
302
+ if (t < 0.75) return "star";
303
+ if (t < 0.92) return "starHeavy";
304
+ return "galaxy";
305
+ }
306
+
307
+ function countAtT(kind, t) {
308
+ if (kind === "dot") return 10 + Math.round(8 * t);
309
+ if (kind === "trail") return 18 + Math.round(10 * t);
310
+ if (kind === "star") return 28;
311
+ if (kind === "starHeavy") return 40;
312
+ if (kind === "galaxy") return 38;
313
+ return 0;
314
+ }
315
+
316
+ function specAt(t) {
317
+ const kind = kindAtT(t);
318
+ return {
319
+ t,
320
+ hue: hueAtT(t),
321
+ kind,
322
+ ribbon: kind !== "none" && kind !== "galaxy" && t >= 0.55,
323
+ n: countAtT(kind, t)
324
+ };
325
+ }
326
+
327
+ /* ------------------------------------------------------------------ *
328
+ * deterministic randomness for rail particles
329
+ * ------------------------------------------------------------------ */
330
+
331
+ function hashStr(s) {
332
+ let h1 = 2166136261;
333
+ for (let i = 0; i < s.length; i++) {
334
+ h1 ^= s.charCodeAt(i);
335
+ h1 = Math.imul(h1, 16777619);
336
+ }
337
+ return h1 >>> 0;
338
+ }
339
+
340
+ function mulberry32(a) {
341
+ let seed = a >>> 0;
342
+ return function () {
343
+ seed = (seed + 0x6d2b79f5) >>> 0;
344
+ let t = seed;
345
+ t = Math.imul(t ^ (t >>> 15), 1 | t);
346
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
347
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
348
+ };
349
+ }
350
+
351
+ /* ------------------------------------------------------------------ *
352
+ * canvas primitives
353
+ * ------------------------------------------------------------------ */
354
+
355
+ function roundRect(c, x, y, w, hh, r) {
356
+ const rr = Math.min(r, w / 2, hh / 2);
357
+ c.beginPath();
358
+ c.moveTo(x + rr, y);
359
+ c.arcTo(x + w, y, x + w, y + hh, rr);
360
+ c.arcTo(x + w, y + hh, x, y + hh, rr);
361
+ c.arcTo(x, y + hh, x, y, rr);
362
+ c.arcTo(x, y, x + w, y, rr);
363
+ c.closePath();
364
+ }
365
+
366
+ function starPath(c, x, y, r) {
367
+ const k = r * 0.07;
368
+ c.beginPath();
369
+ c.moveTo(x, y - r);
370
+ c.quadraticCurveTo(x + k, y - k, x + r, y);
371
+ c.quadraticCurveTo(x + k, y + k, x, y + r);
372
+ c.quadraticCurveTo(x - k, y + k, x - r, y);
373
+ c.quadraticCurveTo(x - k, y - k, x, y - r);
374
+ c.closePath();
375
+ }
376
+
377
+ function star4(c, x, y, r, alpha, hue, irid) {
378
+ if (r <= 0.06 || alpha <= 0.004) return;
379
+ c.fillStyle = "hsla(" + hue + ",100%,92%," + alpha + ")";
380
+ starPath(c, x, y, r);
381
+ c.fill();
382
+ if (!irid) return;
383
+ c.fillStyle = "hsla(" + (hue + 155) + ",100%,70%," + alpha * 0.55 + ")";
384
+ starPath(c, x, y, r * 0.78);
385
+ c.fill();
386
+ c.fillStyle = "hsla(" + (hue + 285) + ",100%,74%," + alpha * 0.42 + ")";
387
+ starPath(c, x, y, r * 0.5);
388
+ c.fill();
389
+ }
390
+
391
+ function starBeam(c, cx, cy, rx, ry) {
392
+ const bow = 0.07;
393
+ c.beginPath();
394
+ c.moveTo(cx - rx, cy);
395
+ c.quadraticCurveTo(cx, cy - ry * bow, cx + rx, cy);
396
+ c.quadraticCurveTo(cx, cy + ry * bow, cx - rx, cy);
397
+ c.closePath();
398
+ c.fill();
399
+ }
400
+
401
+ function nebulaSprite(h1, h2) {
402
+ const size = 256;
403
+ const cv = document.createElement("canvas");
404
+ cv.width = size;
405
+ cv.height = size;
406
+ const c = cv.getContext("2d");
407
+ if (!c) return cv;
408
+ const rnd = mulberry32(hashStr("neb:" + h1 + ":" + h2));
409
+ for (let i = 0; i < 26; i++) {
410
+ const x = size * (0.15 + rnd() * 0.7);
411
+ const y = size * (0.15 + rnd() * 0.7);
412
+ const r = size * (0.08 + rnd() * 0.26);
413
+ const hue = i % 2 === 0 ? h1 : h2;
414
+ const g = c.createRadialGradient(x, y, 0, x, y, r);
415
+ g.addColorStop(0, "hsla(" + hue + ",95%,62%,.20)");
416
+ g.addColorStop(0.45, "hsla(" + (hue + 26) + ",95%,52%,.07)");
417
+ g.addColorStop(1, "hsla(" + hue + ",95%,50%,0)");
418
+ c.fillStyle = g;
419
+ c.beginPath();
420
+ c.arc(x, y, r, 0, Math.PI * 2);
421
+ c.fill();
422
+ }
423
+ return cv;
424
+ }
425
+
426
+ /* ------------------------------------------------------------------ *
427
+ * engine
428
+ * ------------------------------------------------------------------ */
429
+
430
+ function createEngine() {
431
+ const S = {
432
+ mounted: false,
433
+ raf: 0,
434
+ last: 0,
435
+ time: 0,
436
+ frameNo: 0,
437
+ t: 0,
438
+ e: 0,
439
+ idx: 0,
440
+ count: 1,
441
+ anim: null,
442
+ dragging: false,
443
+ warp: 0,
444
+ topFired: false,
445
+ reduce: false,
446
+ els: null,
447
+ bgc: null,
448
+ rxc: null,
449
+ fxc: null,
450
+ W: 0,
451
+ H: 0,
452
+ dpr: 1,
453
+ rw: 0,
454
+ rh: 0,
455
+ rdpr: 1,
456
+ stars: [],
457
+ waves: [],
458
+ debris: [],
459
+ streaks: [],
460
+ astra: [],
461
+ neb: null,
462
+ parts: [],
463
+ partKey: "",
464
+ partKind: "",
465
+ partFade: 1,
466
+ prevParts: [],
467
+ prevKind: "",
468
+ prevFade: 0,
469
+ texts: { boom: "宇宙毁灭", after: "星际探索" },
470
+ onLevel: null,
471
+ onRelease: null,
472
+ bigTimer: 0,
473
+ warpTimer: 0
474
+ };
475
+
476
+ function topIndex() {
477
+ return topIndexOf(S.count);
478
+ }
479
+
480
+ /* ---------------- sizing ---------------- */
481
+
482
+ function fit() {
483
+ const els = S.els;
484
+ if (!els || !els.bg || !els.track) return;
485
+ S.dpr = Math.min(window.devicePixelRatio || 1, 2);
486
+ /* Both canvases live inside the popup, so they must match the popup's
487
+ own box — sizing them to the viewport would put the starfield centre
488
+ far outside the card (and draw a full screen of pixels per frame). */
489
+ let box = null;
490
+ if (els.stage && typeof els.stage.getBoundingClientRect === "function") {
491
+ const r = els.stage.getBoundingClientRect();
492
+ if (r.width > 0.5 && r.height > 0.5) box = r;
493
+ }
494
+ S.W = Math.max(1, box ? box.width : window.innerWidth);
495
+ S.H = Math.max(1, box ? box.height : window.innerHeight);
496
+ const prep = (cv) => {
497
+ if (!cv) return null;
498
+ cv.width = Math.max(1, Math.round(S.W * S.dpr));
499
+ cv.height = Math.max(1, Math.round(S.H * S.dpr));
500
+ cv.style.width = S.W + "px";
501
+ cv.style.height = S.H + "px";
502
+ const c = cv.getContext("2d");
503
+ if (c) c.setTransform(S.dpr, 0, 0, S.dpr, 0, 0);
504
+ return c;
505
+ };
506
+ S.bgc = prep(els.bg);
507
+ S.fxc = prep(els.fx);
508
+ const r = els.track.getBoundingClientRect();
509
+ S.rw = Math.max(1, r.width);
510
+ S.rh = Math.max(1, r.height);
511
+ S.rdpr = Math.min(window.devicePixelRatio || 1, 2);
512
+ if (els.rail) {
513
+ els.rail.width = Math.max(1, Math.round(S.rw * S.rdpr));
514
+ els.rail.height = Math.max(1, Math.round(S.rh * S.rdpr));
515
+ const rc = els.rail.getContext("2d");
516
+ if (rc) rc.setTransform(S.rdpr, 0, 0, S.rdpr, 0, 0);
517
+ S.rxc = rc;
518
+ }
519
+ }
520
+
521
+ /* ---------------- starfield state ---------------- */
522
+
523
+ function respawnStar(s) {
524
+ s.x = Math.random() * 2 - 1;
525
+ s.y = Math.random() * 2 - 1;
526
+ s.z = 0.98;
527
+ s.born = S.time;
528
+ s.life = S.idx === topIndex() ? 6 + Math.random() * 5.5 : 4.2 + Math.random() * 4.2;
529
+ }
530
+
531
+ function initStars() {
532
+ S.stars = [];
533
+ for (let i = 0; i < 260; i++) {
534
+ const s = {
535
+ x: Math.random() * 2 - 1,
536
+ y: Math.random() * 2 - 1,
537
+ z: 0.08 + Math.random() * 0.92,
538
+ c: Math.random() < 0.22 ? 1 : 0,
539
+ born: 0,
540
+ life: 4.2 + Math.random() * 4.2
541
+ };
542
+ S.stars.push(s);
543
+ }
544
+ }
545
+
546
+ /* ---------------- rail particles ---------------- */
547
+
548
+ function buildParticles(n, kind) {
549
+ const key = kind + ":" + n;
550
+ if (key === S.partKey) return;
551
+ S.prevParts = S.parts;
552
+ S.prevKind = S.partKind;
553
+ S.prevFade = S.partFade;
554
+ S.partKey = key;
555
+ S.partKind = kind;
556
+ S.partFade = 0;
557
+ S.parts = [];
558
+ if (n <= 0) return;
559
+ const rnd = mulberry32(hashStr(key));
560
+ for (let i = 0; i < n; i++) {
561
+ S.parts.push({
562
+ p0: rnd(),
563
+ y: 0.1 + rnd() * 0.8,
564
+ sp: 0.016 + rnd() * 0.058,
565
+ size: 0.55 + rnd() * 1.5,
566
+ hueOff: rnd() * 44 - 22,
567
+ tw: rnd() * Math.PI * 2
568
+ });
569
+ }
570
+ }
571
+
572
+ function ensureParticles() {
573
+ const spec = specAt(S.t);
574
+ buildParticles(spec.n, spec.kind);
575
+ }
576
+
577
+ function drawParticleSet(c, list, kind, mul, hue, e, pad, reach, ih) {
578
+ if (!list || !list.length || mul <= 0.002) return;
579
+ const starMode = kind === "star" || kind === "starHeavy" || kind === "galaxy";
580
+ const trailMode = starMode || kind === "trail";
581
+ const speedK = 0.55 + e * 1.7;
582
+ const base = kind === "dot" ? 0.9 : kind === "trail" ? 1.6 : 2.3;
583
+ const copies = starMode ? (kind === "star" ? 2 : 4) : 4;
584
+ const len = starMode ? (kind === "star" ? 15 : 10) : 10;
585
+ for (let i = 0; i < list.length; i++) {
586
+ const q = list[i];
587
+ let p = (q.p0 - S.time * q.sp * speedK) % 1;
588
+ if (p < 0) p += 1;
589
+ const x = pad + reach * p;
590
+ let y = pad + ih * q.y;
591
+ if (kind === "dot") y += Math.sin(q.tw + S.time * 1.3) * 1.2;
592
+ const flick = 0.62 + 0.38 * Math.sin(q.tw + S.time * 2.6);
593
+ const a = (0.2 + e * 0.62) * flick * mul;
594
+ if (a <= 0.004) continue;
595
+ const sz = base * (0.7 + q.size * 0.55);
596
+ if (trailMode) {
597
+ for (let k = 1; k <= copies; k++) {
598
+ const tx = x + len * k;
599
+ if (tx > pad + reach) break;
600
+ const ak = a * (1 - k / (copies + 1)) * 0.55;
601
+ if (starMode) star4(c, tx, y, sz * (1 - k * 0.08), ak * 0.75, hue + q.hueOff + 20, false);
602
+ else {
603
+ c.fillStyle = "hsla(" + (hue + q.hueOff) + ",100%,78%," + ak + ")";
604
+ c.beginPath();
605
+ c.arc(tx, y, Math.max(0.3, sz * 0.8), 0, Math.PI * 2);
606
+ c.fill();
607
+ }
608
+ }
609
+ }
610
+ if (starMode) star4(c, x, y, sz * (kind === "starHeavy" ? 1.4 : 1), a, hue + q.hueOff + 30, true);
611
+ else {
612
+ c.fillStyle = "hsla(" + (hue + q.hueOff) + ",100%,80%," + a + ")";
613
+ c.beginPath();
614
+ c.arc(x, y, Math.max(0.3, sz * (0.7 + e * 0.8)), 0, Math.PI * 2);
615
+ c.fill();
616
+ }
617
+ }
618
+ }
619
+
620
+ /* ---------------- background ---------------- */
621
+
622
+ function drawBG(dt) {
623
+ const c = S.bgc;
624
+ if (!c) return;
625
+ const W = S.W;
626
+ const H = S.H;
627
+ const e = S.e;
628
+ const hue = hueAtT(S.t);
629
+ c.setTransform(S.dpr, 0, 0, S.dpr, 0, 0);
630
+ c.globalCompositeOperation = "source-over";
631
+ c.clearRect(0, 0, W, H);
632
+ const g = c.createRadialGradient(W * 0.5, H * 0.45, 0, W * 0.5, H * 0.45, Math.max(W, H) * 0.85);
633
+ g.addColorStop(0, "hsla(" + (222 + e * 60) + "," + (46 + e * 26) + "%," + (5 + e * 4) + "%,.94)");
634
+ g.addColorStop(1, "rgba(3,4,10,.97)");
635
+ c.fillStyle = g;
636
+ c.fillRect(0, 0, W, H);
637
+
638
+ c.globalCompositeOperation = "lighter";
639
+
640
+ if (S.neb) {
641
+ const sway = Math.sin(S.time * 0.09) * 40;
642
+ const layers = [
643
+ [S.neb.a, 0.12 + e * 0.3, 0.3, sway],
644
+ [S.neb.b, 0.1 + e * 0.26, 0.72, -sway * 0.7],
645
+ [S.neb.c, 0.08 + e * 0.22, 0.5, sway * 0.45]
646
+ ];
647
+ for (let i = 0; i < layers.length; i++) {
648
+ const spr = layers[i][0];
649
+ if (!spr) continue;
650
+ const w = W * 1.5;
651
+ const hh = H * 1.5;
652
+ c.globalAlpha = Math.min(1, layers[i][1]);
653
+ c.drawImage(spr, W * layers[i][2] - w / 2 + layers[i][3], H * 0.5 - hh / 2 + layers[i][3] * 0.4, w, hh);
654
+ }
655
+ c.globalAlpha = 1;
656
+ }
657
+
658
+ const speed = 0.045 + e * 0.55 + S.warp * 2.4;
659
+ const fov = Math.min(W, H) * 0.62;
660
+ const cx = W * 0.5;
661
+ const cy = H * 0.46;
662
+ for (let i = 0; i < S.stars.length; i++) {
663
+ const s = S.stars[i];
664
+ s.z -= dt * speed * (0.35 + (1 - s.z) * 1.6);
665
+ const age = S.time - s.born;
666
+ if (s.z <= 0.012 || age > s.life) {
667
+ respawnStar(s);
668
+ continue;
669
+ }
670
+ const k = fov / s.z;
671
+ const near = 1 - s.z;
672
+ const x = cx + s.x * k;
673
+ const y = cy + s.y * k;
674
+ if (x < -80 || x > W + 80 || y < -80 || y > H + 80) {
675
+ respawnStar(s);
676
+ continue;
677
+ }
678
+ const lifeA = Math.min(1, age / 0.7) * Math.max(0, 1 - age / s.life);
679
+ const a = Math.min(1, near * 1.5) * (0.35 + e * 0.6) * lifeA;
680
+ if (a <= 0.004) continue;
681
+ const tint = s.c ? 268 : 196;
682
+ if (S.warp > 0.04 && near > 0.22) {
683
+ const pz = Math.max(0.02, s.z + dt * speed * 1.6);
684
+ const pk = fov / pz;
685
+ c.strokeStyle = "hsla(" + tint + ",100%,82%," + a * 0.85 + ")";
686
+ c.lineWidth = Math.min(3.2, 0.5 + near * 2.4);
687
+ c.beginPath();
688
+ c.moveTo(x, y);
689
+ c.lineTo(cx + s.x * pk, cy + s.y * pk);
690
+ c.stroke();
691
+ } else {
692
+ c.fillStyle = "hsla(" + tint + ",100%,88%," + a + ")";
693
+ c.beginPath();
694
+ c.arc(x, y, Math.min(2.4, 0.35 + near * 1.9), 0, Math.PI * 2);
695
+ c.fill();
696
+ }
697
+ }
698
+
699
+ for (let i = S.waves.length - 1; i >= 0; i--) {
700
+ const w = S.waves[i];
701
+ w.r += dt * (900 + 900 * w.a);
702
+ w.a *= Math.pow(0.18, dt);
703
+ if (w.a < 0.012) {
704
+ S.waves.splice(i, 1);
705
+ continue;
706
+ }
707
+ c.strokeStyle = "hsla(" + w.hue + ",100%,76%," + w.a + ")";
708
+ c.lineWidth = w.w * Math.max(0.2, w.a);
709
+ c.beginPath();
710
+ c.arc(cx, cy, w.r, 0, Math.PI * 2);
711
+ c.stroke();
712
+ }
713
+
714
+ for (let i = S.debris.length - 1; i >= 0; i--) {
715
+ const d = S.debris[i];
716
+ d.life -= dt * 0.42;
717
+ if (d.life <= 0) {
718
+ S.debris.splice(i, 1);
719
+ continue;
720
+ }
721
+ d.vy += 120 * dt;
722
+ d.vx *= Math.pow(0.55, dt);
723
+ d.x += d.vx * dt;
724
+ d.y += d.vy * dt;
725
+ const a = Math.min(1, d.life) * 0.9;
726
+ c.fillStyle = "hsla(" + d.hue + ",100%,82%," + a + ")";
727
+ c.beginPath();
728
+ c.arc(d.x, d.y, d.size * Math.max(0.3, d.life), 0, Math.PI * 2);
729
+ c.fill();
730
+ }
731
+
732
+ for (let i = S.streaks.length - 1; i >= 0; i--) {
733
+ const s = S.streaks[i];
734
+ s.life -= dt * 1.9;
735
+ if (s.life <= 0) {
736
+ S.streaks.splice(i, 1);
737
+ continue;
738
+ }
739
+ s.x += s.vx * dt;
740
+ s.y += s.vy * dt;
741
+ const a = Math.min(1, s.life) * 0.75;
742
+ c.strokeStyle = "hsla(" + s.hue + ",100%,86%," + a + ")";
743
+ c.lineWidth = 1.4;
744
+ c.beginPath();
745
+ c.moveTo(s.x, s.y);
746
+ c.lineTo(s.x - s.vx * 0.055, s.y - s.vy * 0.055);
747
+ c.stroke();
748
+ }
749
+
750
+ c.globalCompositeOperation = "source-over";
751
+ }
752
+
753
+ /* ---------------- the rail ---------------- */
754
+
755
+ function drawRail() {
756
+ const c = S.rxc;
757
+ if (!c) return;
758
+ const w = S.rw;
759
+ const hh = S.rh;
760
+ c.setTransform(S.rdpr, 0, 0, S.rdpr, 0, 0);
761
+ c.clearRect(0, 0, w, hh);
762
+ const spec = specAt(S.t);
763
+ const e = S.e;
764
+ const hue = hueAtT(S.t);
765
+ const pad = 11;
766
+ const iw = Math.max(1, w - pad * 2);
767
+ const ih = Math.max(1, hh - pad * 2);
768
+ const tx = EDGE + (w - EDGE * 2) * S.t;
769
+ const reach = Math.max(1, tx - pad);
770
+ const cyy = hh * 0.5;
771
+
772
+ c.save();
773
+ roundRect(c, pad, pad, iw, ih, ih / 2);
774
+ c.clip();
775
+
776
+ const bgGrad = c.createLinearGradient(pad, 0, pad + iw, 0);
777
+ bgGrad.addColorStop(0, "hsla(" + (hue - 42) + ",72%," + (8 + e * 4) + "%,.96)");
778
+ bgGrad.addColorStop(0.5, "hsla(" + hue + ",72%," + (10 + e * 6) + "%,.96)");
779
+ bgGrad.addColorStop(1, "hsla(" + (hue + 46) + ",72%," + (9 + e * 5) + "%,.96)");
780
+ c.fillStyle = bgGrad;
781
+ c.fillRect(pad, pad, iw, ih);
782
+
783
+ const fillGrad = c.createLinearGradient(pad, 0, pad + Math.max(1, reach), 0);
784
+ fillGrad.addColorStop(0, "hsla(" + (hue - 34) + ",100%,62%,.55)");
785
+ fillGrad.addColorStop(0.45, "hsla(" + hue + ",100%,66%,.72)");
786
+ fillGrad.addColorStop(0.8, "hsla(" + (hue + 40) + ",100%,68%,.82)");
787
+ fillGrad.addColorStop(1, "hsla(" + (hue + 64) + ",100%,74%,.92)");
788
+ c.fillStyle = fillGrad;
789
+ c.fillRect(pad, pad, reach, ih);
790
+
791
+ c.globalCompositeOperation = "lighter";
792
+
793
+ if (spec.ribbon) {
794
+ const slow = S.time * (0.28 + e * 0.5);
795
+ for (let k = 0; k < 3; k++) {
796
+ const amp = (3 + k * 3.4) * (0.35 + e);
797
+ c.beginPath();
798
+ for (let i = 0; i <= 60; i++) {
799
+ const p = i / 60;
800
+ const x = pad + iw * p;
801
+ const y =
802
+ cyy +
803
+ Math.sin(p * (5.2 + k * 1.7) + slow * (1 + k * 0.35) + k) * amp +
804
+ Math.sin(p * 12 + slow * 1.7) * amp * 0.28;
805
+ if (i === 0) c.moveTo(x, y);
806
+ else c.lineTo(x, y);
807
+ }
808
+ c.strokeStyle = "hsla(" + (hue + 30 + k * 18) + ",100%,78%," + (0.1 + e * 0.26) + ")";
809
+ c.lineWidth = 1.1 + e * 1.5;
810
+ c.stroke();
811
+ }
812
+ }
813
+
814
+ c.save();
815
+ c.beginPath();
816
+ c.rect(pad, pad, reach, ih);
817
+ c.clip();
818
+ if (S.prevParts.length && S.prevFade > 0.01) drawParticleSet(c, S.prevParts, S.prevKind, S.prevFade, hue, e, pad, reach, ih);
819
+ drawParticleSet(c, S.parts, S.partKind, S.partFade, hue, e, pad, reach, ih);
820
+
821
+ if (spec.kind === "starHeavy") {
822
+ const sc = 0.6 + e * 0.7;
823
+ const br = 0.5 + 0.5 * Math.sin(S.time * 2.4);
824
+ const cxx = pad + iw * 0.5;
825
+ const halo = c.createRadialGradient(cxx, cyy, 0, cxx, cyy, 26 * sc);
826
+ halo.addColorStop(0, "hsla(" + hue + ",100%,90%," + (0.22 + br * 0.3) + ")");
827
+ halo.addColorStop(1, "hsla(" + hue + ",100%,70%,0)");
828
+ c.fillStyle = halo;
829
+ c.beginPath();
830
+ c.arc(cxx, cyy, 26 * sc, 0, Math.PI * 2);
831
+ c.fill();
832
+ star4(c, cxx, cyy, (13 + br * 3) * sc, 0.75 + br * 0.2, hue, true);
833
+ star4(c, cxx - iw * 0.17, cyy, 7 * sc, 0.42, hue + 40, false);
834
+ star4(c, cxx + iw * 0.17, cyy, 7 * sc, 0.42, hue - 30, false);
835
+ }
836
+
837
+ if (spec.kind === "galaxy") {
838
+ const ga = clamp01((e - 0.9) / 0.1);
839
+ const cxx = pad + iw * 0.5;
840
+ const core = c.createRadialGradient(cxx, cyy, 0, cxx, cyy, iw * 0.24);
841
+ core.addColorStop(0, "rgba(255,255,255,.95)");
842
+ core.addColorStop(0.35, "hsla(196,100%,82%,.75)");
843
+ core.addColorStop(0.72, "hsla(258,100%,62%,.34)");
844
+ core.addColorStop(1, "hsla(258,100%,60%,0)");
845
+ c.fillStyle = core;
846
+ c.beginPath();
847
+ c.arc(cxx, cyy, iw * 0.24, 0, Math.PI * 2);
848
+ c.fill();
849
+ for (let a = 0; a < 3; a++) {
850
+ for (let i = 0; i < 130; i++) {
851
+ const f = i / 130;
852
+ const ang = a * ((Math.PI * 2) / 3) + f * 5.4 + S.time * 0.55;
853
+ const rad = f * iw * 0.46;
854
+ const x = cxx + Math.cos(ang) * rad;
855
+ const y = cyy + Math.sin(ang) * rad * 0.42;
856
+ const alpha = (1 - f) * 0.5 * (0.4 + ga * 0.6);
857
+ c.fillStyle = "hsla(" + (196 + a * 32) + ",100%," + (86 - f * 18) + "%," + alpha + ")";
858
+ c.beginPath();
859
+ c.arc(x, y, 0.5 + (1 - f) * 1.5, 0, Math.PI * 2);
860
+ c.fill();
861
+ }
862
+ }
863
+ }
864
+ c.restore();
865
+
866
+ const gloss = c.createLinearGradient(0, pad, 0, pad + ih);
867
+ gloss.addColorStop(0, "rgba(255,255,255,.16)");
868
+ gloss.addColorStop(0.35, "rgba(255,255,255,.05)");
869
+ gloss.addColorStop(0.55, "rgba(255,255,255,0)");
870
+ gloss.addColorStop(1, "rgba(0,0,0,.18)");
871
+ c.fillStyle = gloss;
872
+ c.fillRect(pad, pad, iw, ih);
873
+
874
+ c.globalCompositeOperation = "source-over";
875
+ c.restore();
876
+
877
+ c.strokeStyle = "hsla(" + hue + ",100%,72%," + (0.18 + e * 0.5) + ")";
878
+ c.lineWidth = 1.2;
879
+ c.shadowColor = "hsla(" + hue + ",100%,68%,.9)";
880
+ c.shadowBlur = 10 + e * 26;
881
+ roundRect(c, pad + 0.5, pad + 0.5, iw - 1, ih - 1, ih / 2);
882
+ c.stroke();
883
+ c.shadowBlur = 0;
884
+ }
885
+
886
+ /* ---------------- light blades ---------------- */
887
+
888
+ function drawFx(dt) {
889
+ const c = S.fxc;
890
+ if (!c) return;
891
+ c.setTransform(S.dpr, 0, 0, S.dpr, 0, 0);
892
+ c.clearRect(0, 0, S.W, S.H);
893
+ if (!S.astra.length || !S.els || !S.els.track) return;
894
+ const r = S.els.track.getBoundingClientRect();
895
+ const cx = r.left + r.width / 2;
896
+ const cy = r.top + r.height / 2;
897
+ const k = Math.min(1.25, 0.35 + (r.width / 900) * 0.9);
898
+ for (let i = S.astra.length - 1; i >= 0; i--) {
899
+ const f = S.astra[i];
900
+ f.age += dt;
901
+ const p = f.age / f.life;
902
+ if (p >= 1) {
903
+ S.astra.splice(i, 1);
904
+ continue;
905
+ }
906
+ const p2 = p * p;
907
+ const th = 13 * (1 - p2) * (1 - p2 * 0.35);
908
+ const baseRy = r.height * 0.55;
909
+ const halfL = r.width * 0.62;
910
+ for (let L = 0; L < 14; L++) {
911
+ const lt = L / 13;
912
+ const a = (0.022 + 0.95 * Math.pow(lt, 2.3)) * (1 - p2) * 0.8;
913
+ if (a <= 0.004) continue;
914
+ const col = Math.round(90 + 165 * Math.pow(lt, 0.7)) + "," + Math.round(150 + 105 * Math.pow(lt, 0.5)) + ",255";
915
+ c.fillStyle = "rgba(" + col + "," + a + ")";
916
+ const ry = Math.max(0.5, th * (0.4 + L * 0.06));
917
+ starBeam(c, cx, cy, halfL * (0.55 + L * 0.03), ry);
918
+ c.save();
919
+ c.translate(cx, cy);
920
+ c.rotate(Math.PI / 2);
921
+ c.translate(-cx, -cy);
922
+ starBeam(c, cx, cy, baseRy * (0.35 + L * 0.05), Math.max(0.4, ry * 0.45));
923
+ c.restore();
924
+ }
925
+ const cr = Math.max(1, (8 + 34 * (1 - p2)) * k);
926
+ const g = c.createRadialGradient(cx, cy, 0, cx, cy, cr);
927
+ g.addColorStop(0, "rgba(255,255,255," + 0.85 * (1 - p2) + ")");
928
+ g.addColorStop(0.5, "rgba(190,215,255," + 0.35 * (1 - p2) + ")");
929
+ g.addColorStop(1, "rgba(120,160,255,0)");
930
+ c.fillStyle = g;
931
+ c.beginPath();
932
+ c.arc(cx, cy, cr, 0, Math.PI * 2);
933
+ c.fill();
934
+ }
935
+ }
936
+
937
+ /* ---------------- celebration ---------------- */
938
+
939
+ function spawnStreaks() {
940
+ for (let i = 0; i < 24; i++) {
941
+ const ang = Math.random() * Math.PI * 2;
942
+ const sp = 380 + Math.random() * 980;
943
+ S.streaks.push({
944
+ x: S.W * 0.5,
945
+ y: S.H * 0.5,
946
+ vx: Math.cos(ang) * sp,
947
+ vy: Math.sin(ang) * sp,
948
+ life: 1,
949
+ hue: 180 + Math.random() * 150
950
+ });
951
+ }
952
+ }
953
+
954
+ function boom() {
955
+ const els = S.els;
956
+ if (!els) return;
957
+ S.astra.push({ age: 0, life: 0.9 });
958
+ if (S.reduce) return;
959
+ if (els.stage && els.stage.classList) {
960
+ els.stage.classList.remove("es-quake");
961
+ void els.stage.offsetWidth;
962
+ els.stage.classList.add("es-quake");
963
+ }
964
+ if (els.flash && els.flash.classList) {
965
+ els.flash.classList.remove("go");
966
+ void els.flash.offsetWidth;
967
+ els.flash.classList.add("go");
968
+ }
969
+ if (els.big && els.big.classList) {
970
+ els.big.textContent = S.texts.boom;
971
+ els.big.classList.remove("go");
972
+ void els.big.offsetWidth;
973
+ els.big.classList.add("go");
974
+ clearTimeout(S.bigTimer);
975
+ S.bigTimer = setTimeout(() => {
976
+ if (els.big) els.big.textContent = S.texts.after;
977
+ }, 2900);
978
+ }
979
+ const alphas = [0.95, 0.7, 0.5, 0.35];
980
+ const widths = [6, 14, 26, 40];
981
+ const hues = [190, 300, 46, 210];
982
+ for (let i = 0; i < 4; i++) S.waves.push({ r: 0, a: alphas[i], w: widths[i], hue: hues[i] });
983
+ for (let i = 0; i < 220; i++) {
984
+ const ang = Math.random() * Math.PI * 2;
985
+ const sp = 60 + Math.random() * 620;
986
+ S.debris.push({
987
+ x: S.W * 0.5,
988
+ y: S.H * 0.5,
989
+ vx: Math.cos(ang) * sp,
990
+ vy: Math.sin(ang) * sp,
991
+ life: 1,
992
+ hue: [190, 300, 45][i % 3],
993
+ size: 0.6 + Math.random() * 2.2
994
+ });
995
+ }
996
+ clearTimeout(S.warpTimer);
997
+ S.warpTimer = setTimeout(() => {
998
+ S.warp = 1;
999
+ }, 220);
1000
+ }
1001
+
1002
+ /* ---------------- loop ---------------- */
1003
+
1004
+ function syncLevel() {
1005
+ const top = topIndex();
1006
+ const i = top > 0 ? Math.round(S.t * top) : 0;
1007
+ if (i !== S.idx) {
1008
+ S.idx = i;
1009
+ if (S.onLevel) {
1010
+ try {
1011
+ S.onLevel(i);
1012
+ } catch (err) {
1013
+ /* a listener error must not stop the animation */
1014
+ }
1015
+ }
1016
+ }
1017
+ const isTop = top > 0 && i === top;
1018
+ if (isTop && !S.topFired) {
1019
+ S.topFired = true;
1020
+ boom();
1021
+ } else if (!isTop) {
1022
+ S.topFired = false;
1023
+ if (S.warp > 0.01) S.warp = 0;
1024
+ }
1025
+ }
1026
+
1027
+ function paintDom() {
1028
+ const el = S.els && S.els.stage;
1029
+ if (!el || !el.style) return;
1030
+ el.style.setProperty("--es-t", S.t.toFixed(4));
1031
+ el.style.setProperty("--es-hue", String(Math.round(hueAtT(S.t))));
1032
+ el.style.setProperty("--es-e", S.e.toFixed(3));
1033
+ }
1034
+
1035
+ function loop(now) {
1036
+ if (!S.mounted) return;
1037
+ S.raf = requestAnimationFrame(loop);
1038
+ const dt = Math.min(0.05, S.last ? (now - S.last) / 1000 : 0.016);
1039
+ S.last = now;
1040
+ S.time += dt;
1041
+ S.frameNo++;
1042
+
1043
+ if (S.anim) {
1044
+ const p = clamp01((now - S.anim.start) / S.anim.dur);
1045
+ S.t = clamp01(S.anim.from + (S.anim.to - S.anim.from) * easeOutBack(p));
1046
+ if (p >= 1) {
1047
+ S.t = S.anim.to;
1048
+ S.anim = null;
1049
+ }
1050
+ }
1051
+
1052
+ S.e += (S.t - S.e) * Math.min(1, dt * (S.dragging ? 14 : 6));
1053
+ S.partFade = Math.min(1, S.partFade + dt / 0.28);
1054
+ if (S.prevFade > 0) S.prevFade = Math.max(0, S.prevFade - dt / 0.24);
1055
+
1056
+ syncLevel();
1057
+ ensureParticles();
1058
+
1059
+ const top = topIndex();
1060
+ if (!S.reduce && top > 0 && S.idx === top && S.frameNo % 10 === 0) spawnStreaks();
1061
+
1062
+ drawBG(dt);
1063
+ drawRail();
1064
+ drawFx(dt);
1065
+ paintDom();
1066
+ }
1067
+
1068
+ /* ---------------- pointer / keyboard ---------------- */
1069
+
1070
+ function tFromEvent(ev) {
1071
+ const el = S.els && S.els.track;
1072
+ if (!el) return S.t;
1073
+ const r = el.getBoundingClientRect();
1074
+ const span = r.width - EDGE * 2;
1075
+ if (span <= 1) return 0;
1076
+ const x = clamp(ev.clientX - r.left, EDGE, r.width - EDGE);
1077
+ return clamp01((x - EDGE) / span);
1078
+ }
1079
+
1080
+ function beginDrag(ev) {
1081
+ if (!S.mounted) return;
1082
+ S.dragging = true;
1083
+ S.anim = null;
1084
+ S.t = tFromEvent(ev);
1085
+ try {
1086
+ if (ev.currentTarget && ev.currentTarget.setPointerCapture) ev.currentTarget.setPointerCapture(ev.pointerId);
1087
+ } catch (err) {
1088
+ /* capture is a nicety */
1089
+ }
1090
+ ev.preventDefault();
1091
+ }
1092
+
1093
+ function onDocMove(ev) {
1094
+ if (!S.dragging) return;
1095
+ S.t = tFromEvent(ev);
1096
+ }
1097
+
1098
+ function onDocUp() {
1099
+ if (!S.dragging) return;
1100
+ S.dragging = false;
1101
+ snapTo(levelT(S.idx, S.count), 300);
1102
+ if (S.onRelease) {
1103
+ try {
1104
+ S.onRelease(S.idx);
1105
+ } catch (err) {
1106
+ /* ignore */
1107
+ }
1108
+ }
1109
+ }
1110
+
1111
+ function onKey(ev) {
1112
+ if (!S.mounted) return;
1113
+ /* The rail is now inline in the composer, so the caret may be in the
1114
+ prompt box while this listener is live. Never steal arrow keys from
1115
+ an editable target. */
1116
+ const target = ev.target;
1117
+ if (target && typeof target === "object") {
1118
+ const tag = String(target.tagName || "").toUpperCase();
1119
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable === true) return;
1120
+ }
1121
+ const top = topIndex();
1122
+ let next = null;
1123
+ if (ev.key === "ArrowRight" || ev.key === "ArrowUp") next = Math.min(top, S.idx + 1);
1124
+ else if (ev.key === "ArrowLeft" || ev.key === "ArrowDown") next = Math.max(0, S.idx - 1);
1125
+ else if (ev.key === "Home") next = 0;
1126
+ else if (ev.key === "End") next = top;
1127
+ if (next === null) return;
1128
+ ev.preventDefault();
1129
+ snapTo(levelT(next, S.count), ev.key === "Home" || ev.key === "End" ? 420 : 300);
1130
+ if (S.onRelease) {
1131
+ try {
1132
+ S.onRelease(next);
1133
+ } catch (err) {
1134
+ /* ignore */
1135
+ }
1136
+ }
1137
+ }
1138
+
1139
+ function snapTo(to, dur) {
1140
+ S.anim = { from: S.t, to: clamp01(to), start: performance.now(), dur: dur || 300 };
1141
+ }
1142
+
1143
+ /* ---------------- lifecycle ---------------- */
1144
+
1145
+ function mount(opts) {
1146
+ S.els = opts.els;
1147
+ S.onLevel = opts.onLevel;
1148
+ S.onRelease = opts.onRelease;
1149
+ S.texts = opts.texts || S.texts;
1150
+ S.reduce = !!opts.reduce;
1151
+ S.count = Math.max(1, (opts.count || 1) | 0);
1152
+ S.mounted = true;
1153
+ S.time = 0;
1154
+ S.frameNo = 0;
1155
+ S.last = 0;
1156
+ S.e = S.t;
1157
+ S.topFired = false;
1158
+ S.warp = 0;
1159
+ S.waves.length = 0;
1160
+ S.debris.length = 0;
1161
+ S.streaks.length = 0;
1162
+ S.astra.length = 0;
1163
+ S.parts = [];
1164
+ S.prevParts = [];
1165
+ S.partKey = "";
1166
+ S.partKind = "";
1167
+ S.partFade = 1;
1168
+ S.prevFade = 0;
1169
+ initStars();
1170
+ if (!S.neb) S.neb = { a: nebulaSprite(216, 262), b: nebulaSprite(286, 196), c: nebulaSprite(190, 320) };
1171
+ fit();
1172
+ requestAnimationFrame(() => {
1173
+ if (S.mounted) fit();
1174
+ });
1175
+ window.addEventListener("resize", fit);
1176
+ window.addEventListener("orientationchange", fit);
1177
+ /* The popup grows and shrinks as the model list opens and closes. */
1178
+ if (typeof ResizeObserver === "function" && S.els && S.els.stage) {
1179
+ S.ro = new ResizeObserver(() => {
1180
+ if (S.mounted) fit();
1181
+ });
1182
+ try {
1183
+ S.ro.observe(S.els.stage);
1184
+ } catch (err) {
1185
+ S.ro = null;
1186
+ }
1187
+ }
1188
+ window.addEventListener("pointermove", onDocMove);
1189
+ window.addEventListener("pointerup", onDocUp);
1190
+ window.addEventListener("pointercancel", onDocUp);
1191
+ window.addEventListener("keydown", onKey);
1192
+ S.raf = requestAnimationFrame(loop);
1193
+ }
1194
+
1195
+ function unmount() {
1196
+ S.mounted = false;
1197
+ if (S.raf) cancelAnimationFrame(S.raf);
1198
+ S.raf = 0;
1199
+ clearTimeout(S.bigTimer);
1200
+ clearTimeout(S.warpTimer);
1201
+ window.removeEventListener("resize", fit);
1202
+ window.removeEventListener("orientationchange", fit);
1203
+ if (S.ro) {
1204
+ try {
1205
+ S.ro.disconnect();
1206
+ } catch (err) {}
1207
+ S.ro = null;
1208
+ }
1209
+ window.removeEventListener("pointermove", onDocMove);
1210
+ window.removeEventListener("pointerup", onDocUp);
1211
+ window.removeEventListener("pointercancel", onDocUp);
1212
+ window.removeEventListener("keydown", onKey);
1213
+ S.els = null;
1214
+ S.bgc = null;
1215
+ S.rxc = null;
1216
+ S.fxc = null;
1217
+ }
1218
+
1219
+ function setCount(n) {
1220
+ const next = Math.max(1, n | 0);
1221
+ if (next === S.count) return;
1222
+ S.count = next;
1223
+ S.idx = Math.min(S.idx, topIndexOf(S.count));
1224
+ }
1225
+
1226
+ function setIndex(index, immediate) {
1227
+ const top = topIndex();
1228
+ const to = levelT(clamp(index, 0, top), S.count);
1229
+ if (immediate) {
1230
+ S.anim = null;
1231
+ S.t = to;
1232
+ S.e = to;
1233
+ S.idx = Math.round(to * top);
1234
+ /*
1235
+ * Opening the panel must not replay the top-level celebration. Only a
1236
+ * real move into the top level (drag, click, keyboard) fires boom();
1237
+ * mounting while already at the top just arms the guard.
1238
+ */
1239
+ S.topFired = S.count > 1 && S.idx === top;
1240
+ } else {
1241
+ snapTo(to, 320);
1242
+ }
1243
+ }
1244
+
1245
+ return {
1246
+ mount,
1247
+ unmount,
1248
+ setCount,
1249
+ setIndex,
1250
+ beginDrag,
1251
+ isDragging() {
1252
+ return S.dragging;
1253
+ },
1254
+ isMounted() {
1255
+ return S.mounted;
1256
+ },
1257
+ index() {
1258
+ return S.idx;
1259
+ }
1260
+ };
1261
+ }
1262
+
1263
+ /* ------------------------------------------------------------------ *
1264
+ * glyphs
1265
+ * ------------------------------------------------------------------ */
1266
+
1267
+ function boltGlyph(size, hue) {
1268
+ return h(
1269
+ "svg",
1270
+ { width: size, height: size, viewBox: "0 0 24 24", "aria-hidden": "true", style: { display: "block" } },
1271
+ h("path", {
1272
+ d: "M13.6 2 5.2 13.1h5.3L9.9 22l8.9-11.6h-5.6L13.6 2Z",
1273
+ fill: "currentColor",
1274
+ opacity: 0.92
1275
+ }),
1276
+ h("path", {
1277
+ d: "M13.6 2 5.2 13.1h5.3L9.9 22l8.9-11.6h-5.6L13.6 2Z",
1278
+ fill: "none",
1279
+ stroke: "hsl(" + hue + " 100% 78%)",
1280
+ strokeWidth: 0.7,
1281
+ opacity: 0.8
1282
+ })
1283
+ );
1284
+ }
1285
+
1286
+ function resetGlyph() {
1287
+ return h(
1288
+ "svg",
1289
+ { width: 13, height: 13, viewBox: "0 0 24 24", "aria-hidden": "true" },
1290
+ h("path", {
1291
+ d: "M4 12a8 8 0 1 0 2.5-5.8",
1292
+ fill: "none",
1293
+ stroke: "currentColor",
1294
+ strokeWidth: 2,
1295
+ strokeLinecap: "round"
1296
+ }),
1297
+ h("path", { d: "M4 4v5h5", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round" })
1298
+ );
1299
+ }
1300
+
1301
+ function closeGlyph() {
1302
+ return h(
1303
+ "svg",
1304
+ { width: 13, height: 13, viewBox: "0 0 24 24", "aria-hidden": "true" },
1305
+ h("path", { d: "M6 6l12 12M18 6L6 18", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round" })
1306
+ );
1307
+ }
1308
+
1309
+ /* ------------------------------------------------------------------ *
1310
+ * controller
1311
+ * ------------------------------------------------------------------ */
1312
+
1313
+ function findModel(catalog, sel) {
1314
+ if (!catalog || !sel) return void 0;
1315
+ const groups = Array.isArray(catalog.groups) ? catalog.groups : [];
1316
+ for (let i = 0; i < groups.length; i++) {
1317
+ const g = groups[i];
1318
+ const models = g && Array.isArray(g.models) ? g.models : [];
1319
+ for (let j = 0; j < models.length; j++) {
1320
+ const m = models[j];
1321
+ if (m && m.id === sel.model && (!sel.provider || g.id === sel.provider)) return m;
1322
+ }
1323
+ }
1324
+ for (let i = 0; i < groups.length; i++) {
1325
+ const models = groups[i] && Array.isArray(groups[i].models) ? groups[i].models : [];
1326
+ for (let j = 0; j < models.length; j++) if (models[j] && models[j].id === sel.model) return models[j];
1327
+ }
1328
+ return void 0;
1329
+ }
1330
+
1331
+ /*
1332
+ * Reasoning-effort ids form one shared scale even though providers declare
1333
+ * different subsets of it (off/low/high/max here, off/minimal/low/medium/
1334
+ * high/xhigh/max there). Ranking on that shared scale is what lets a level
1335
+ * the user already picked be mapped onto a model that lacks it.
1336
+ */
1337
+ const EFFORT_LADDER = ["off", "none", "minimal", "low", "medium", "med", "high", "xhigh", "max", "ultra"];
1338
+
1339
+ /** The effort levels one catalogue model declares, as plain objects. */
1340
+ function modelEfforts(model) {
1341
+ const reasoning = model && model.reasoning ? model.reasoning : void 0;
1342
+ const efforts = reasoning && Array.isArray(reasoning.efforts) ? reasoning.efforts : [];
1343
+ return efforts
1344
+ .filter((ef) => ef && ef.id !== void 0 && ef.id !== null)
1345
+ .map((ef) => ({
1346
+ id: String(ef.id),
1347
+ name: String(ef.name || ef.id),
1348
+ description: ef.description ? String(ef.description) : ""
1349
+ }));
1350
+ }
1351
+
1352
+ /**
1353
+ * Map the effort being carried onto the effort list of the model being
1354
+ * switched to.
1355
+ *
1356
+ * `{ id }` the carried level is supported as-is
1357
+ * `{ id, from }` the model lacks it and the closest rung was swapped in
1358
+ * `{ id: null, from }` the model declares no reasoning at all, so the
1359
+ * request must omit `reasoningEffort` entirely (the
1360
+ * Host rejects any explicit effort for such a model)
1361
+ */
1362
+ function resolveEffort(model, wantedId) {
1363
+ const levels = modelEfforts(model);
1364
+ const wanted = wantedId === void 0 || wantedId === null ? "" : String(wantedId);
1365
+ if (levels.length === 0) return { id: null, from: wanted };
1366
+ if (!wanted) return { id: null, from: "" };
1367
+ if (levels.some((lv) => lv.id === wanted)) return { id: wanted, from: wanted };
1368
+ const wantRank = EFFORT_LADDER.indexOf(wanted.toLowerCase());
1369
+ if (wantRank < 0) {
1370
+ /* A provider-specific name that is not on the shared scale: its
1371
+ distance is unknowable, so prefer this model's own default and only
1372
+ then its strongest rung. */
1373
+ const fallback = model.reasoning && model.reasoning.defaultEffort ? String(model.reasoning.defaultEffort) : "";
1374
+ if (fallback && levels.some((lv) => lv.id === fallback)) return { id: fallback, from: wanted };
1375
+ return { id: levels[levels.length - 1].id, from: wanted };
1376
+ }
1377
+ let best = 0;
1378
+ let bestGap = Infinity;
1379
+ let bestScaled = -Infinity;
1380
+ for (let i = 0; i < levels.length; i++) {
1381
+ const rank = EFFORT_LADDER.indexOf(levels[i].id.toLowerCase());
1382
+ /* An off-ladder rung still has to sit somewhere: spread the unknown
1383
+ ones evenly across the interior of the scale, in declared order. */
1384
+ const scaled = rank >= 0 ? rank : 1 + (levels.length > 1 ? (i / (levels.length - 1)) * (EFFORT_LADDER.length - 2) : 0);
1385
+ const gap = Math.abs(scaled - wantRank);
1386
+ /* Equal distance keeps the stronger rung — silently dropping reasoning
1387
+ power is the worse of the two surprises — and it is decided on the
1388
+ scale, not on declaration order, so a provider that lists its levels
1389
+ the other way round still gets the strong pick. */
1390
+ if (gap < bestGap - 1e-9 || (gap < bestGap + 1e-9 && scaled > bestScaled)) {
1391
+ bestGap = Math.min(gap, bestGap);
1392
+ bestScaled = scaled;
1393
+ best = i;
1394
+ }
1395
+ }
1396
+ return { id: levels[best].id, from: wanted };
1397
+ }
1398
+
1399
+ /** Display name of one effort id on a model, falling back to the id. */
1400
+ function effortName(model, id) {
1401
+ const hit = modelEfforts(model).filter((lv) => lv.id === id)[0];
1402
+ return hit ? hit.name : String(id);
1403
+ }
1404
+
1405
+ /**
1406
+ * The composer card that owns the trigger. The shipped DOM uses hashed
1407
+ * CSS-module names (`<hash>_card`), so match that suffix first and fall
1408
+ * back to "the nearest rounded box that owns a text field".
1409
+ */
1410
+ /* A preference, not state: the halo can be switched off, and the choice
1411
+ outlives the page. localStorage is guarded because a host context may
1412
+ refuse it. */
1413
+ const HALO_KEY = "effort-slider:halo";
1414
+ function readHaloPref() {
1415
+ try {
1416
+ if (typeof window === "undefined" || !window.localStorage) return true;
1417
+ const raw = window.localStorage.getItem(HALO_KEY);
1418
+ return raw === null ? true : raw !== "0";
1419
+ } catch (err) {
1420
+ return true;
1421
+ }
1422
+ }
1423
+ function writeHaloPref(on) {
1424
+ try {
1425
+ if (typeof window === "undefined" || !window.localStorage) return;
1426
+ window.localStorage.setItem(HALO_KEY, on ? "1" : "0");
1427
+ } catch (err) {
1428
+ /* a preference that cannot persist still applies to this page */
1429
+ }
1430
+ }
1431
+
1432
+ function composerBox(el) {
1433
+ if (!el || typeof el.closest !== "function") return null;
1434
+ const card = el.closest('[class*="_card"]');
1435
+ if (card && card.querySelector && card.querySelector('textarea,[contenteditable="true"],[role="textbox"]')) return card;
1436
+ let node = el.parentElement;
1437
+ while (node && node !== document.body) {
1438
+ if (node.querySelector && node.querySelector('textarea,[contenteditable="true"],[role="textbox"]')) {
1439
+ const cs = window.getComputedStyle(node);
1440
+ if ((parseFloat(cs.borderTopLeftRadius) || 0) >= 10) return node;
1441
+ }
1442
+ node = node.parentElement;
1443
+ }
1444
+ return null;
1445
+ }
1446
+
1447
+ /** The page shares one ring, so a second mount cannot leave a ghost. */
1448
+ let HALO = null;
1449
+ function haloLayer() {
1450
+ if (HALO === null) HALO = createHalo();
1451
+ return HALO;
1452
+ }
1453
+
1454
+ /**
1455
+ * One fixed ring that hugs the composer. It is created once and reused, so
1456
+ * re-renders never stack copies; the geometry follows the card through a
1457
+ * ResizeObserver plus the window events that can move it.
1458
+ */
1459
+ function createHalo() {
1460
+ let node = null;
1461
+ let target = null;
1462
+ let observer = null;
1463
+ let frame = 0;
1464
+ let mounts = 0;
1465
+ let anchor = null;
1466
+ let pollTimer = 0;
1467
+ let until = 0;
1468
+ let lastGeom = "";
1469
+
1470
+ function paint() {
1471
+ if (!node) return;
1472
+ /*
1473
+ * The host may rebuild the composer node (a session switch, a layout
1474
+ * change) without telling us; look the card up again from the trigger
1475
+ * rather than keeping a ring pinned to a detached node.
1476
+ */
1477
+ if ((!target || !target.isConnected) && anchor && anchor.isConnected) {
1478
+ const again = composerBox(anchor);
1479
+ if (again !== target) {
1480
+ target = again;
1481
+ if (again && typeof window.ResizeObserver === "function") {
1482
+ if (observer && typeof observer.disconnect === "function") observer.disconnect();
1483
+ observer = new window.ResizeObserver(schedule);
1484
+ observer.observe(again);
1485
+ }
1486
+ }
1487
+ }
1488
+ if (!target || !target.isConnected) {
1489
+ hide();
1490
+ return;
1491
+ }
1492
+ const rect = target.getBoundingClientRect();
1493
+ if (!rect.width || !rect.height || rect.bottom < 0 || rect.top > window.innerHeight) {
1494
+ hide();
1495
+ return;
1496
+ }
1497
+ /*
1498
+ * The composer moves without resizing — a Session switch turns the
1499
+ * centred empty state into the docked conversation view, and a pure
1500
+ * ResizeObserver never fires for that. Re-read the geometry instead of
1501
+ * trusting the last placed rect, but only touch the DOM when it moved.
1502
+ */
1503
+ const key = rect.left + "," + rect.top + "," + rect.width + "," + rect.height;
1504
+ if (key === lastGeom && node.dataset.on === "1") return;
1505
+ lastGeom = key;
1506
+ const radius = parseFloat(window.getComputedStyle(target).borderTopLeftRadius) || 16;
1507
+ node.style.left = rect.left - 2 + "px";
1508
+ node.style.top = rect.top - 2 + "px";
1509
+ node.style.width = rect.width + 4 + "px";
1510
+ node.style.height = rect.height + 4 + "px";
1511
+ node.style.borderRadius = radius + 2 + "px";
1512
+ }
1513
+
1514
+ /** Frames follow the card closely for a moment after anything may have moved it. */
1515
+ function watchFor(ms) {
1516
+ until = Math.max(until, Date.now() + ms);
1517
+ if (frame) return;
1518
+ frame = window.requestAnimationFrame(track);
1519
+ }
1520
+
1521
+ function track() {
1522
+ frame = 0;
1523
+ if (!node) return;
1524
+ paint();
1525
+ if (!target || Date.now() >= until) return;
1526
+ frame = window.requestAnimationFrame(track);
1527
+ }
1528
+
1529
+ function schedule() {
1530
+ watchFor(1200);
1531
+ }
1532
+
1533
+ /* A layout change can also land long after the switch, so a slow poll
1534
+ keeps watching for as long as the ring exists. */
1535
+ function poll() {
1536
+ pollTimer = setTimeout(() => {
1537
+ pollTimer = 0;
1538
+ if (!node) return;
1539
+ paint();
1540
+ poll();
1541
+ }, 250);
1542
+ }
1543
+
1544
+ function startPoll() {
1545
+ if (pollTimer || typeof setTimeout !== "function") return;
1546
+ poll();
1547
+ }
1548
+
1549
+ function stopPoll() {
1550
+ if (!pollTimer) return;
1551
+ clearTimeout(pollTimer);
1552
+ pollTimer = 0;
1553
+ }
1554
+
1555
+ /** Leaving no paint behind matters more than leaving no state behind. */
1556
+ function hide() {
1557
+ if (!node) return;
1558
+ node.dataset.on = "0";
1559
+ node.dataset.hot = "0";
1560
+ node.style.opacity = "0.000";
1561
+ node.style.visibility = "hidden";
1562
+ lastGeom = "";
1563
+ }
1564
+
1565
+ function ensure() {
1566
+ if (node) return node;
1567
+ /* An earlier copy of this module may have left its ring in the body. */
1568
+ if (typeof document.querySelectorAll === "function") {
1569
+ const stale = document.querySelectorAll(".es-halo");
1570
+ for (let i = 0; i < stale.length; i += 1) {
1571
+ if (stale[i].parentNode) stale[i].parentNode.removeChild(stale[i]);
1572
+ }
1573
+ }
1574
+ node = document.createElement("div");
1575
+ node.className = "es-halo";
1576
+ node.setAttribute("aria-hidden", "true");
1577
+ document.body.appendChild(node);
1578
+ window.addEventListener("resize", schedule, { passive: true });
1579
+ document.addEventListener("scroll", schedule, { capture: true, passive: true });
1580
+ return node;
1581
+ }
1582
+
1583
+ function place(box, opts, from) {
1584
+ const el = ensure();
1585
+ if (from) anchor = from;
1586
+ if (box !== target) {
1587
+ target = box;
1588
+ if (observer && typeof observer.disconnect === "function") observer.disconnect();
1589
+ observer = null;
1590
+ if (target && typeof window.ResizeObserver === "function") {
1591
+ observer = new window.ResizeObserver(schedule);
1592
+ observer.observe(target);
1593
+ }
1594
+ lastGeom = "";
1595
+ if (target) startPoll();
1596
+ watchFor(3000);
1597
+ }
1598
+ const heat = Math.max(0, Math.min(1, Number(opts.heat) || 0));
1599
+ el.style.setProperty("--es-hue", String(opts.hue | 0));
1600
+ el.style.setProperty("--es-a0", (0.1 + heat * 0.34).toFixed(3));
1601
+ el.style.setProperty("--es-a1", (0.04 + heat * 0.14).toFixed(3));
1602
+ el.style.setProperty("--es-a2", (0.1 + heat * 0.44).toFixed(3));
1603
+ el.style.setProperty("--es-a3", (0.05 + heat * 0.3).toFixed(3));
1604
+ el.style.setProperty("--es-b1", (10 + heat * 26).toFixed(1) + "px");
1605
+ el.style.setProperty("--es-b2", (34 + heat * 78).toFixed(1) + "px");
1606
+ el.style.opacity = String((!target ? 0 : opts.open ? 0.62 + heat * 0.38 : 0.2 + heat * 0.8).toFixed(3));
1607
+ el.style.visibility = target ? "visible" : "hidden";
1608
+ el.dataset.on = target ? "1" : "0";
1609
+ el.dataset.hot = target && opts.hot ? "1" : "0";
1610
+ if (!target) hide();
1611
+ else schedule();
1612
+ }
1613
+
1614
+ function retain() {
1615
+ mounts += 1;
1616
+ }
1617
+
1618
+ function release() {
1619
+ mounts = Math.max(0, mounts - 1);
1620
+ if (mounts > 0) return;
1621
+ stopPoll();
1622
+ lastGeom = "";
1623
+ if (observer && typeof observer.disconnect === "function") observer.disconnect();
1624
+ observer = null;
1625
+ target = null;
1626
+ anchor = null;
1627
+ hide();
1628
+ }
1629
+
1630
+ return { place, retain, release };
1631
+ }
1632
+
1633
+ class EffortController {
1634
+ constructor(ctx) {
1635
+ this.ctx = ctx;
1636
+ this.catalog = null;
1637
+ this.selection = null;
1638
+ this.levels = [];
1639
+ this.defaultIndex = 0;
1640
+ this.sessionId = void 0;
1641
+ this.loading = false;
1642
+ /* A read requested while another is in flight is queued, not dropped:
1643
+ the correction that follows a Session switch almost always arrives
1644
+ during the first catalogue read, and dropping it left the pill on
1645
+ the deployment default until something else happened to refresh. */
1646
+ this.refreshQueued = false;
1647
+ this.bindTimer = 0;
1648
+ this.bindTries = 0;
1649
+ this.commitTimer = 0;
1650
+ this.pendingIndex = -1;
1651
+ this.noticeTimer = 0;
1652
+ /* The last effort the user actually chose, kept so a round-trip through
1653
+ a model that cannot express it returns to that level, not a default. */
1654
+ this.lastEffort = "";
1655
+ this.token = 0;
1656
+ this.bindT = null;
1657
+ this.selFace = void 0;
1658
+ this.selUnsub = void 0;
1659
+ this.store = createStore({
1660
+ open: false,
1661
+ listOpen: false,
1662
+ levels: [],
1663
+ models: [],
1664
+ index: 0,
1665
+ t: 0,
1666
+ busy: false,
1667
+ error: null,
1668
+ notice: null,
1669
+ supported: true,
1670
+ ready: false,
1671
+ modelName: "",
1672
+ modelId: "",
1673
+ provider: "",
1674
+ halo: readHaloPref()
1675
+ });
1676
+ }
1677
+
1678
+ bindLocale() {
1679
+ const bind = readService(() => this.ctx.locale.bind(NS));
1680
+ if (typeof bind === "function") this.bindT = bind;
1681
+ }
1682
+
1683
+ t(key, vars) {
1684
+ let out = void 0;
1685
+ if (this.bindT) {
1686
+ try {
1687
+ const v = this.bindT(key, vars);
1688
+ if (typeof v === "string" && v.length && v !== key) out = v;
1689
+ } catch (err) {
1690
+ /* fall through to the bundled dictionary */
1691
+ }
1692
+ }
1693
+ if (out === void 0) {
1694
+ out = typeof zh[key] === "string" ? zh[key] : key;
1695
+ if (vars) {
1696
+ Object.keys(vars).forEach((k) => {
1697
+ out = out.split("{" + k + "}").join(String(vars[k]));
1698
+ });
1699
+ }
1700
+ }
1701
+ return out;
1702
+ }
1703
+
1704
+ face() {
1705
+ return {
1706
+ store: this.store,
1707
+ t: (k, v) => this.t(k, v),
1708
+ open: () => this.open(),
1709
+ close: () => this.close(),
1710
+ toggle: () => this.toggle(),
1711
+ toggleModels: () => this.toggleModels(),
1712
+ toggleHalo: () => this.toggleHalo(),
1713
+ chooseModel: (provider, modelId) => this.chooseModel(provider, modelId),
1714
+ reset: () => this.reset(),
1715
+ commit: (i) => this.commit(i),
1716
+ setIndex: (i) => this.setIndex(i),
1717
+ setSession: (id) => this.setSession(id)
1718
+ };
1719
+ }
1720
+
1721
+ toggleHalo() {
1722
+ const on = !this.store.getSnapshot().halo;
1723
+ writeHaloPref(on);
1724
+ this.store.set({ halo: on });
1725
+ }
1726
+
1727
+ dispose() {
1728
+ clearTimeout(this.commitTimer);
1729
+ if (this.bindTimer) {
1730
+ clearTimeout(this.bindTimer);
1731
+ this.bindTimer = 0;
1732
+ }
1733
+ this.dropSelectionSubscription();
1734
+ }
1735
+
1736
+ /* ---------------- state ---------------- */
1737
+
1738
+ setIndex(index) {
1739
+ const snap = this.store.getSnapshot();
1740
+ const top = topIndexOf(snap.levels.length);
1741
+ const i = clamp(index | 0, 0, top);
1742
+ if (i === snap.index && Math.abs(snap.t - levelT(i, snap.levels.length)) < 0.001) return;
1743
+ this.store.set({ index: i, t: levelT(i, snap.levels.length) });
1744
+ }
1745
+
1746
+ setSession(id) {
1747
+ if (!id || id === this.sessionId) return;
1748
+ this.sessionId = id;
1749
+ /* Load the level names for the pill itself, so it shows the real effort
1750
+ instead of the generic label before the panel is ever opened. */
1751
+ this.refresh();
1752
+ }
1753
+
1754
+ /**
1755
+ * The Session's durable model-selection projection — the same source the
1756
+ * shipped selector reads. `projections.faceOf("modelSelection")` yields
1757
+ * `{ lastUsed, next }` where `next = pending ?? lastUsed`: a
1758
+ * `model/selection` event carries the user's intent immediately, and a
1759
+ * `request/header` records what a request actually used. `catalog.default`
1760
+ * is only the deployment default and can differ from what this Session
1761
+ * runs, so it is a fallback only.
1762
+ */
1763
+ selectionFace() {
1764
+ const sessionId = this.resolveSessionId();
1765
+ if (!sessionId) return void 0;
1766
+ const sessions = readService(() => this.ctx.sessions);
1767
+ if (!sessions || typeof sessions.binding !== "function") return void 0;
1768
+ let binding;
1769
+ try {
1770
+ binding = sessions.binding(sessionId);
1771
+ } catch (err) {
1772
+ return void 0;
1773
+ }
1774
+ const session = binding && binding.session;
1775
+ const projections = session && session.projections;
1776
+ if (!projections || typeof projections.faceOf !== "function") return void 0;
1777
+ try {
1778
+ return projections.faceOf("modelSelection");
1779
+ } catch (err) {
1780
+ return void 0;
1781
+ }
1782
+ }
1783
+
1784
+ /** Current projected selection, or undefined when the projection has no value yet. */
1785
+ projectedSelection() {
1786
+ const face = this.selectionFace();
1787
+ if (!face || typeof face.getSnapshot !== "function") return void 0;
1788
+ let view;
1789
+ try {
1790
+ view = face.getSnapshot();
1791
+ } catch (err) {
1792
+ return void 0;
1793
+ }
1794
+ const next = view && view.next;
1795
+ if (!next || typeof next.provider !== "string" || typeof next.model !== "string") return void 0;
1796
+ return next;
1797
+ }
1798
+
1799
+ dropSelectionSubscription() {
1800
+ if (this.selUnsub) {
1801
+ try {
1802
+ this.selUnsub();
1803
+ } catch (err) {
1804
+ /* an already-disposed face throws on unsubscribe */
1805
+ }
1806
+ }
1807
+ this.selUnsub = void 0;
1808
+ this.selFace = void 0;
1809
+ }
1810
+
1811
+ /**
1812
+ * Follow the Session's projection so a selection made anywhere else (the
1813
+ * `/model` command, a restored Session) updates this control instead of
1814
+ * leaving a stale label in the composer.
1815
+ */
1816
+ bindSelection() {
1817
+ const face = this.selectionFace();
1818
+ /* Both being undefined is not "already bound": a Session that was just
1819
+ created has no binding yet, and comparing undefined to undefined
1820
+ used to skip the retry that fixes it. */
1821
+ if (face) {
1822
+ if (face === this.selFace) return;
1823
+ } else {
1824
+ this.scheduleBind();
1825
+ return;
1826
+ }
1827
+ this.dropSelectionSubscription();
1828
+ if (typeof face.subscribe !== "function") {
1829
+ this.scheduleBind();
1830
+ return;
1831
+ }
1832
+ this.bindTries = 0;
1833
+ if (this.bindTimer) {
1834
+ clearTimeout(this.bindTimer);
1835
+ this.bindTimer = 0;
1836
+ }
1837
+ this.selFace = face;
1838
+ this.selUnsub = face.subscribe(() => {
1839
+ /* Opening the face and later commits both notify; defer so the
1840
+ in-flight refresh guard has cleared before this one starts. */
1841
+ setTimeout(() => {
1842
+ this.refresh();
1843
+ }, 0);
1844
+ });
1845
+ }
1846
+
1847
+ scheduleBind() {
1848
+ if (this.bindTimer || this.bindTries >= 12) return;
1849
+ const delay = this.bindTries < 6 ? 200 : 1000;
1850
+ this.bindTries += 1;
1851
+ this.bindTimer = setTimeout(() => {
1852
+ this.bindTimer = 0;
1853
+ this.bindSelection();
1854
+ this.refresh();
1855
+ }, delay);
1856
+ }
1857
+
1858
+ resolveSessionId() {
1859
+ if (this.sessionId) return this.sessionId;
1860
+ const sessions = readService(() => this.ctx.sessions);
1861
+ if (!sessions || !sessions.list || typeof sessions.list.getSnapshot !== "function") return void 0;
1862
+ let snap = void 0;
1863
+ try {
1864
+ snap = sessions.list.getSnapshot();
1865
+ } catch (err) {
1866
+ return void 0;
1867
+ }
1868
+ return mainViewSessionId(snap && snap.byId);
1869
+ }
1870
+
1871
+ open() {
1872
+ this.store.set({ open: true });
1873
+ this.refresh();
1874
+ }
1875
+
1876
+ close() {
1877
+ this.store.set({ open: false, listOpen: false });
1878
+ }
1879
+
1880
+ toggle() {
1881
+ if (this.store.getSnapshot().open) this.close();
1882
+ else this.open();
1883
+ }
1884
+
1885
+ toggleModels() {
1886
+ const snap = this.store.getSnapshot();
1887
+ this.store.set({ listOpen: !snap.listOpen });
1888
+ }
1889
+
1890
+ reset() {
1891
+ const target = this.defaultIndex;
1892
+ this.setIndex(target);
1893
+ this.commit(target);
1894
+ }
1895
+
1896
+ /**
1897
+ * Switch the Session-local model while keeping the reasoning effort the
1898
+ * user already picked, then reload the catalogue because the new model
1899
+ * exposes its own effort list.
1900
+ */
1901
+ async chooseModel(provider, modelId) {
1902
+ const snap = this.store.getSnapshot();
1903
+ if (snap.provider === provider && snap.modelId === modelId) {
1904
+ this.store.set({ listOpen: false });
1905
+ return;
1906
+ }
1907
+ const remote = readService(() => this.ctx.remote.session);
1908
+ if (!remote || typeof remote.selectModel !== "function") {
1909
+ this.store.set({ error: "selectModel is unavailable in this build" });
1910
+ return;
1911
+ }
1912
+ const sessionId = this.resolveSessionId();
1913
+ if (!sessionId) {
1914
+ this.store.set({ error: this.t("noSession") });
1915
+ return;
1916
+ }
1917
+ const level = this.levels[snap.index];
1918
+ if (level) this.lastEffort = level.id;
1919
+ const target = findModel(this.catalog, { provider: String(provider), model: String(modelId) });
1920
+ /* The Host rejects an explicit effort the target model does not declare
1921
+ (UNSUPPORTED_REASONING_EFFORT) and performs no clamping of its own, so
1922
+ the carried level is mapped onto the target's rungs before asking.
1923
+ An unknown target keeps the old shape and lets the Host be the
1924
+ authority. */
1925
+ const mapped = target ? resolveEffort(target, level ? level.id : this.lastEffort) : { id: level ? level.id : null, from: "" };
1926
+ const request = { sessionId, provider: String(provider), model: String(modelId) };
1927
+ if (mapped.id) request.reasoningEffort = mapped.id;
1928
+ const token = ++this.token;
1929
+ this.store.set({ busy: true, error: null, notice: null, listOpen: false });
1930
+ try {
1931
+ const res = await withTimeout(remote.selectModel(request), SELECT_TIMEOUT_MS);
1932
+ if (token !== this.token) return;
1933
+ if (res && res.ok) {
1934
+ const selected = res.value && res.value.selected;
1935
+ const next = Object.assign({}, this.selection || {}, selected || {}, {
1936
+ provider: request.provider,
1937
+ model: request.model
1938
+ });
1939
+ if (mapped.id) next.reasoningEffort = mapped.id;
1940
+ else delete next.reasoningEffort;
1941
+ this.selection = next;
1942
+ this.store.set({ busy: false, error: null, notice: this.note(this.switchNotice(target, level, mapped)) });
1943
+ this.loading = false;
1944
+ this.refresh();
1945
+ } else {
1946
+ this.store.set({ busy: false, error: messageOf(res && res.error) || "refused" });
1947
+ }
1948
+ } catch (err) {
1949
+ if (token !== this.token) return;
1950
+ this.store.set({ busy: false, error: messageOf(err) });
1951
+ }
1952
+ }
1953
+
1954
+ /**
1955
+ * One short line saying what the switch had to change, or null when the
1956
+ * carried effort was accepted verbatim.
1957
+ */
1958
+ switchNotice(target, level, mapped) {
1959
+ if (!target || !level) return null;
1960
+ if (mapped.id === null) return this.t("noEffortModel", { model: target.name || target.id || "" });
1961
+ if (mapped.id !== level.id) return this.t("clamped", { from: level.name || level.id, to: effortName(target, mapped.id) });
1962
+ return null;
1963
+ }
1964
+
1965
+ /** Show a transient footer line; it expires on its own and never sticks. */
1966
+ note(text) {
1967
+ if (!text) return null;
1968
+ clearTimeout(this.noticeTimer);
1969
+ const shown = text;
1970
+ this.noticeTimer = setTimeout(() => {
1971
+ if (this.store.getSnapshot().notice === shown) this.store.set({ notice: null });
1972
+ }, 9000);
1973
+ return shown;
1974
+ }
1975
+
1976
+ /* ---------------- catalogue ---------------- */
1977
+
1978
+ async refresh() {
1979
+ if (this.loading) {
1980
+ this.refreshQueued = true;
1981
+ return;
1982
+ }
1983
+ this.loading = true;
1984
+ try {
1985
+ do {
1986
+ this.refreshQueued = false;
1987
+ await this.refreshOnce();
1988
+ } while (this.refreshQueued);
1989
+ } finally {
1990
+ this.loading = false;
1991
+ }
1992
+ }
1993
+
1994
+ async refreshOnce() {
1995
+ /* Reading a projection before subscribing to it can return no value at
1996
+ all, so the follow-up starts the subscription first. */
1997
+ this.bindSelection();
1998
+ let remote;
1999
+ try {
2000
+ remote = this.ctx.remote.session;
2001
+ } catch (err) {
2002
+ this.store.set({ supported: false, ready: true, levels: [], modelName: "", error: null, debug: "inject:" + messageOf(err) });
2003
+ return;
2004
+ }
2005
+ if (!remote || typeof remote.modelCatalog !== "function") {
2006
+ this.store.set({
2007
+ supported: false,
2008
+ ready: true,
2009
+ levels: [],
2010
+ modelName: "",
2011
+ error: null,
2012
+ debug: "shape:" + (remote ? Object.keys(remote).slice(0, 8).join("+") : "undef")
2013
+ });
2014
+ return;
2015
+ }
2016
+ try {
2017
+ const res = await withTimeout(remote.modelCatalog(), SELECT_TIMEOUT_MS);
2018
+ if (!res || !res.ok) {
2019
+ this.store.set({ ready: true, error: messageOf(res && res.error) || "modelCatalog failed", debug: "res:" + (res ? String(res.ok) : "null") });
2020
+ return;
2021
+ }
2022
+ const catalog = res.value || {};
2023
+ this.catalog = catalog;
2024
+ const fallback = catalog.default || {};
2025
+ /* The durable projection is authoritative: it carries the user's
2026
+ pending intent immediately after a `model/selection` event and the
2027
+ last actually-used configuration after a `request/header`. */
2028
+ const projected = this.projectedSelection();
2029
+ const sel = Object.assign({}, fallback, projected || {});
2030
+ this.selection = Object.assign({}, sel);
2031
+ const model = findModel(catalog, sel) || findModel(catalog, fallback);
2032
+ const reasoning = model && model.reasoning ? model.reasoning : void 0;
2033
+ const efforts = reasoning && Array.isArray(reasoning.efforts) ? reasoning.efforts : [];
2034
+ const levels = efforts
2035
+ .filter((ef) => ef && ef.id !== void 0 && ef.id !== null)
2036
+ .map((ef) => ({
2037
+ id: String(ef.id),
2038
+ name: String(ef.name || ef.id),
2039
+ description: ef.description ? String(ef.description) : ""
2040
+ }));
2041
+ const fallbackId = reasoning && reasoning.defaultEffort ? String(reasoning.defaultEffort) : "";
2042
+ let index = levels.findIndex((l) => l.id === String(sel.reasoningEffort || ""));
2043
+ if (index < 0 && fallbackId) index = levels.findIndex((l) => l.id === fallbackId);
2044
+ if (index < 0) index = Math.max(0, levels.length - 1);
2045
+ this.levels = levels;
2046
+ this.defaultIndex = index;
2047
+ const groups = Array.isArray(catalog.groups) ? catalog.groups : [];
2048
+ const models = groups.map((group) => ({
2049
+ id: String(group && group.id != null ? group.id : ""),
2050
+ name: String((group && (group.name || group.id)) || ""),
2051
+ models: (Array.isArray(group && group.models) ? group.models : [])
2052
+ .filter((m) => m && m.id != null)
2053
+ .map((m) => ({
2054
+ id: String(m.id),
2055
+ name: String(m.name || m.id),
2056
+ description: m.description ? String(m.description) : "",
2057
+ efforts: m.reasoning && Array.isArray(m.reasoning.efforts) ? m.reasoning.efforts.length : 0
2058
+ }))
2059
+ }));
2060
+ this.store.set({
2061
+ levels,
2062
+ models,
2063
+ modelId: String(sel.model || ""),
2064
+ provider: String(sel.provider || ""),
2065
+ index,
2066
+ t: levelT(index, levels.length),
2067
+ supported: levels.length > 0,
2068
+ ready: true,
2069
+ error: null,
2070
+ debug: "ok:" + levels.length,
2071
+ modelName: model ? String(model.name || model.id || "") : String(sel.model || "")
2072
+ });
2073
+ } catch (err) {
2074
+ this.store.set({ ready: true, error: messageOf(err), debug: "throw:" + messageOf(err) });
2075
+ }
2076
+ }
2077
+
2078
+ /* ---------------- commit ---------------- */
2079
+
2080
+ commit(index) {
2081
+ this.setIndex(index);
2082
+ this.pendingIndex = index;
2083
+ clearTimeout(this.commitTimer);
2084
+ this.commitTimer = setTimeout(() => {
2085
+ this.flush();
2086
+ }, COMMIT_DEBOUNCE_MS);
2087
+ }
2088
+
2089
+ async flush() {
2090
+ const index = this.pendingIndex;
2091
+ this.pendingIndex = -1;
2092
+ if (index < 0) return;
2093
+ const level = this.levels[index];
2094
+ if (!level) return;
2095
+ const remote = readService(() => this.ctx.remote.session);
2096
+ if (!remote || typeof remote.selectModel !== "function") {
2097
+ this.store.set({ error: "selectModel is unavailable in this build" });
2098
+ return;
2099
+ }
2100
+ const sessionId = this.resolveSessionId();
2101
+ if (!sessionId) {
2102
+ this.store.set({ error: this.t("noSession") });
2103
+ return;
2104
+ }
2105
+ const base = this.selection || {};
2106
+ const currentModel = this.catalog ? findModel(this.catalog, base) : void 0;
2107
+ /* A drag is normally inside the current model's own rungs; resolving
2108
+ anyway keeps a stale level (switch still in flight) from being sent
2109
+ as an explicit unsupported effort. */
2110
+ const mapped = currentModel ? resolveEffort(currentModel, level.id) : { id: level.id, from: level.id };
2111
+ /* Send the explicit triple the shipped selector sends, so the Host never
2112
+ has to infer provider/model from a partial request. */
2113
+ const request = {
2114
+ sessionId,
2115
+ provider: String(base.provider || ""),
2116
+ model: String(base.model || ""),
2117
+ ...(mapped.id ? { reasoningEffort: mapped.id } : {})
2118
+ };
2119
+ const token = ++this.token;
2120
+ this.store.set({ busy: true, error: null, notice: null });
2121
+ try {
2122
+ const res = await withTimeout(remote.selectModel(request), SELECT_TIMEOUT_MS);
2123
+ if (token !== this.token) return;
2124
+ if (res && res.ok) {
2125
+ const selected = res.value && res.value.selected;
2126
+ const applied = Object.assign({}, base, selected || {});
2127
+ if (mapped.id) applied.reasoningEffort = mapped.id;
2128
+ else delete applied.reasoningEffort;
2129
+ if (mapped.id) this.lastEffort = mapped.id;
2130
+ this.selection = Object.assign({}, applied);
2131
+ /* The Host appends a `model/selection` event, so the projection now
2132
+ reports this choice; re-read it to confirm and pick up names. */
2133
+ this.store.set({
2134
+ busy: false,
2135
+ error: null,
2136
+ modelId: String(applied.model || ""),
2137
+ provider: String(applied.provider || "")
2138
+ });
2139
+ this.refresh();
2140
+ } else {
2141
+ this.store.set({ busy: false, error: messageOf(res && res.error) || "refused" });
2142
+ }
2143
+ } catch (err) {
2144
+ if (token !== this.token) return;
2145
+ this.store.set({ busy: false, error: messageOf(err) });
2146
+ }
2147
+ }
2148
+ }
2149
+
2150
+ /* ------------------------------------------------------------------ *
2151
+ * components
2152
+ * ------------------------------------------------------------------ */
2153
+
2154
+ function chevronGlyph() {
2155
+ return h(
2156
+ "svg",
2157
+ { width: 11, height: 11, viewBox: "0 0 24 24", "aria-hidden": "true", style: { display: "block" } },
2158
+ h("path", {
2159
+ d: "M6 9l6 6 6-6",
2160
+ fill: "none",
2161
+ stroke: "currentColor",
2162
+ strokeWidth: 2.2,
2163
+ strokeLinecap: "round",
2164
+ strokeLinejoin: "round"
2165
+ })
2166
+ );
2167
+ }
2168
+
2169
+ /**
2170
+ * The whole control lives in the composer's model cell: a trigger that
2171
+ * reads "model · effort" plus a popup that carries the model list and the
2172
+ * glowing effort rail.
2173
+ */
2174
+ function ModelEffortControl(props) {
2175
+ const snap = react.useSyncExternalStore(props.store.subscribe, props.store.getSnapshot);
2176
+ const sid = props.sessionId;
2177
+ react.useEffect(() => {
2178
+ if (sid && typeof props.setSession === "function") props.setSession(sid);
2179
+ }, [sid]);
2180
+
2181
+ const triggerRef = react.useRef(null);
2182
+ const menuRef = react.useRef(null);
2183
+ const trackRef = react.useRef(null);
2184
+ const bgRef = react.useRef(null);
2185
+ const railRef = react.useRef(null);
2186
+ const fxRef = react.useRef(null);
2187
+ const flashRef = react.useRef(null);
2188
+ const bigRef = react.useRef(null);
2189
+ const engineRef = react.useRef(null);
2190
+ const levelsRef = react.useRef(snap.levels);
2191
+ levelsRef.current = snap.levels;
2192
+
2193
+ const posState = react.useState(null);
2194
+ const pos = posState[0];
2195
+ const setPos = posState[1];
2196
+
2197
+ if (engineRef.current === null) engineRef.current = createEngine();
2198
+
2199
+ /* The glow that used to sit on the pill now wraps the composer card. */
2200
+ react.useEffect(() => {
2201
+ const halo = haloLayer();
2202
+ halo.retain();
2203
+ return () => halo.release();
2204
+ }, []);
2205
+ react.useEffect(() => {
2206
+ const halo = haloLayer();
2207
+ const count = snap.levels.length;
2208
+ if (!snap.halo || !snap.ready || !count) {
2209
+ /* Hide, but keep the mount counted: release() belongs to unmount. */
2210
+ halo.place(null, { hue: 0, heat: 0, open: false, hot: false });
2211
+ return;
2212
+ }
2213
+ const heat = count > 1 ? snap.index / (count - 1) : 0;
2214
+ halo.place(
2215
+ composerBox(triggerRef.current),
2216
+ {
2217
+ hue: Math.round(hueAtT(snap.t)),
2218
+ heat: heat,
2219
+ open: snap.open,
2220
+ hot: heat >= 0.85
2221
+ },
2222
+ triggerRef.current
2223
+ );
2224
+ }, [snap.halo, snap.ready, snap.index, snap.levels.length, snap.open, snap.t]);
2225
+
2226
+ /* Anchor the popup above the trigger, clamped into the viewport. */
2227
+ react.useEffect(() => {
2228
+ if (!snap.open) {
2229
+ setPos(null);
2230
+ return;
2231
+ }
2232
+ const place = () => {
2233
+ const el = triggerRef.current;
2234
+ if (!el || typeof el.getBoundingClientRect !== "function") return;
2235
+ const rect = el.getBoundingClientRect();
2236
+ /* A detached or hidden trigger reports an all-zero box; keep the last good spot. */
2237
+ if (rect.width < 1 && rect.height < 1) return;
2238
+ const vw = window.innerWidth || 1024;
2239
+ const vh = window.innerHeight || 768;
2240
+ const width = Math.min(400, Math.max(240, vw - 24));
2241
+ /* Right-align the popup to the trigger, the way a composer menu reads. */
2242
+ const left = Math.max(8, Math.min(rect.right - width, vw - width - 8));
2243
+ /*
2244
+ * Open upward by default (the trigger sits in the composer, near the
2245
+ * bottom). On a short viewport — a landscape phone, a small window —
2246
+ * there may be almost no room above, so fall back to opening downward
2247
+ * and cap the height to the space that is actually available. Without
2248
+ * this the popup ran off the top of the screen.
2249
+ */
2250
+ const gap = 8;
2251
+ const spaceAbove = rect.top - gap - 8;
2252
+ const spaceBelow = vh - rect.bottom - gap - 8;
2253
+ const up = spaceAbove >= 220 || spaceAbove >= spaceBelow;
2254
+ const room = Math.max(140, up ? spaceAbove : spaceBelow);
2255
+ setPos(
2256
+ up
2257
+ ? { left, width, bottom: vh - rect.top + gap, maxHeight: room, top: null }
2258
+ : { left, width, top: rect.bottom + gap, maxHeight: room, bottom: null }
2259
+ );
2260
+ };
2261
+ place();
2262
+ window.addEventListener("resize", place);
2263
+ window.addEventListener("scroll", place, true);
2264
+ return () => {
2265
+ window.removeEventListener("resize", place);
2266
+ window.removeEventListener("scroll", place, true);
2267
+ };
2268
+ }, [snap.open]);
2269
+
2270
+ const ready = snap.open && pos !== null;
2271
+
2272
+ react.useEffect(() => {
2273
+ if (!ready) return;
2274
+ const engine = engineRef.current;
2275
+ if (!engine) return;
2276
+ const reduce =
2277
+ typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
2278
+ engine.mount({
2279
+ els: {
2280
+ stage: menuRef.current,
2281
+ track: trackRef.current,
2282
+ bg: bgRef.current,
2283
+ rail: railRef.current,
2284
+ fx: fxRef.current,
2285
+ flash: flashRef.current,
2286
+ big: bigRef.current
2287
+ },
2288
+ count: levelsRef.current.length,
2289
+ texts: { boom: props.t("boom"), after: props.t("afterBoom") },
2290
+ reduce,
2291
+ onLevel: (i) => props.setIndex(i),
2292
+ onRelease: (i) => props.commit(i)
2293
+ });
2294
+ engine.setIndex(snap.index, true);
2295
+ return () => engine.unmount();
2296
+ }, [ready]);
2297
+
2298
+ react.useEffect(() => {
2299
+ const engine = engineRef.current;
2300
+ if (!engine || !ready || !engine.isMounted()) return;
2301
+ engine.setCount(snap.levels.length);
2302
+ if (!engine.isDragging()) engine.setIndex(snap.index, true);
2303
+ }, [snap.levels.length]);
2304
+
2305
+ /* Dismiss on an outside press or Escape. */
2306
+ react.useEffect(() => {
2307
+ if (!snap.open) return;
2308
+ const onDown = (ev) => {
2309
+ const target = ev.target;
2310
+ const menu = menuRef.current;
2311
+ const trigger = triggerRef.current;
2312
+ if (menu && typeof menu.contains === "function" && menu.contains(target)) return;
2313
+ if (trigger && typeof trigger.contains === "function" && trigger.contains(target)) return;
2314
+ props.close();
2315
+ };
2316
+ const onEsc = (ev) => {
2317
+ if (ev.key === "Escape") props.close();
2318
+ };
2319
+ document.addEventListener("pointerdown", onDown, true);
2320
+ document.addEventListener("keydown", onEsc, true);
2321
+ return () => {
2322
+ document.removeEventListener("pointerdown", onDown, true);
2323
+ document.removeEventListener("keydown", onEsc, true);
2324
+ };
2325
+ }, [snap.open]);
2326
+
2327
+ const hue = Math.round(hueAtT(snap.t));
2328
+ const current = snap.levels[snap.index];
2329
+ const n = snap.levels.length;
2330
+ const modelText = snap.modelId || snap.modelName || "";
2331
+ const effortText = current ? current.name : "";
2332
+ const locked = props.locked === true;
2333
+ const summary = props.t("triggerHint") + (modelText ? " — " + modelText : "") + (effortText ? " · " + effortText : "") + (snap.error ? " · " + snap.error : "");
2334
+
2335
+ const trigger = h(
2336
+ "button",
2337
+ {
2338
+ ref: triggerRef,
2339
+ key: "trigger",
2340
+ type: "button",
2341
+ className: "es-trigger",
2342
+ title: summary,
2343
+ "aria-label": summary,
2344
+ "aria-haspopup": "dialog",
2345
+ "aria-expanded": snap.open ? "true" : "false",
2346
+ "data-open": snap.open ? "1" : "0",
2347
+ "data-locked": locked ? "1" : "0",
2348
+ "data-halo": snap.halo ? "1" : "0",
2349
+ disabled: locked,
2350
+ style: { "--es-hue": String(hue), "--es-e": snap.t.toFixed(3) },
2351
+ onClick: () => props.toggle()
2352
+ },
2353
+ h("span", { className: "es-trigger-bolt", key: "b" }, boltGlyph(14, hue)),
2354
+ h("span", { className: "es-trigger-text", key: "m" }, safeLabel(modelText) || props.t("modelLabel")),
2355
+ effortText ? h("span", { className: "es-trigger-sep", key: "s" }, "·") : null,
2356
+ effortText ? h("span", { className: "es-trigger-effort", key: "e" }, safeLabel(effortText)) : null,
2357
+ h("span", { className: "es-trigger-chev", key: "c" }, chevronGlyph())
2358
+ );
2359
+
2360
+ if (!ready) return trigger;
2361
+
2362
+ const footText = snap.error
2363
+ ? props.t("failed", { message: snap.error })
2364
+ : snap.busy
2365
+ ? props.t("switching")
2366
+ : snap.notice
2367
+ ? snap.notice
2368
+ : !snap.supported && snap.ready
2369
+ ? props.t("noReasoning")
2370
+ : current && current.description
2371
+ ? current.description
2372
+ : props.t("hint");
2373
+ const footNote = !snap.error && !snap.busy && !!snap.notice;
2374
+
2375
+ const modelNodes = [];
2376
+ snap.models.forEach((group) => {
2377
+ if (group.name) modelNodes.push(h("div", { key: "gh-" + group.id, className: "es-models-group" }, safeLabel(group.name)));
2378
+ group.models.forEach((entry) => {
2379
+ const on = entry.id === snap.modelId && group.id === snap.provider;
2380
+ modelNodes.push(
2381
+ h(
2382
+ "button",
2383
+ {
2384
+ key: "mi-" + group.id + "-" + entry.id,
2385
+ type: "button",
2386
+ className: "es-model-item",
2387
+ "data-on": on ? "1" : "0",
2388
+ title: entry.description || entry.name,
2389
+ onClick: () => props.chooseModel(group.id, entry.id)
2390
+ },
2391
+ h("span", { className: "es-model-item-name", key: "n" }, safeLabel(entry.name)),
2392
+ entry.efforts === 0 ? h("span", { className: "es-model-item-tag", key: "tag" }, props.t("noLevelsTag")) : null
2393
+ )
2394
+ );
2395
+ });
2396
+ });
2397
+
2398
+ const menu = h(
2399
+ "div",
2400
+ {
2401
+ ref: menuRef,
2402
+ key: "menu",
2403
+ className: "es-menu",
2404
+ role: "dialog",
2405
+ "aria-label": props.t("panelTitle"),
2406
+ style: {
2407
+ left: pos.left + "px",
2408
+ ...(pos.top === null || pos.top === void 0 ? {} : { top: pos.top + "px" }),
2409
+ ...(pos.bottom === null || pos.bottom === void 0 ? {} : { bottom: pos.bottom + "px" }),
2410
+ maxHeight: pos.maxHeight + "px",
2411
+ width: pos.width + "px",
2412
+ "--es-hue": String(hue),
2413
+ "--es-t": snap.t.toFixed(4),
2414
+ "--es-e": snap.t.toFixed(3)
2415
+ }
2416
+ },
2417
+ h("canvas", { className: "es-menu-bg", key: "bg", ref: bgRef }),
2418
+ h("div", { className: "es-menu-vig", key: "vig" }),
2419
+ h(
2420
+ "div",
2421
+ { className: "es-menu-body", key: "body" },
2422
+ h(
2423
+ "button",
2424
+ {
2425
+ className: "es-menu-row",
2426
+ key: "row",
2427
+ type: "button",
2428
+ "data-open": snap.listOpen ? "1" : "0",
2429
+ "aria-expanded": snap.listOpen ? "true" : "false",
2430
+ onClick: () => props.toggleModels()
2431
+ },
2432
+ h("span", { className: "es-menu-row-label", key: "l" }, props.t("modelLabel")),
2433
+ h("span", { className: "es-menu-row-value", key: "v", title: modelText }, safeLabel(modelText) || props.t("noModels")),
2434
+ h("span", { className: "es-menu-chev", key: "c" }, chevronGlyph())
2435
+ ),
2436
+ snap.listOpen
2437
+ ? h(
2438
+ "div",
2439
+ { className: "es-models", key: "models" },
2440
+ modelNodes.length
2441
+ ? modelNodes
2442
+ : h("div", { className: "es-models-group", key: "none" }, props.t("noModels"))
2443
+ )
2444
+ : null,
2445
+ h("div", { className: "es-menu-sep", key: "sep" }),
2446
+ h(
2447
+ "div",
2448
+ { className: "es-effort-head", key: "eh" },
2449
+ h("span", { className: "es-head-glyph", key: "g" }, boltGlyph(13, hue)),
2450
+ h("span", { key: "t" }, props.t("effortLabel")),
2451
+ current ? h("span", { className: "es-tag", key: "tag" }, current.name) : null
2452
+ ),
2453
+ h(
2454
+ "div",
2455
+ { className: "es-track", key: "track", ref: trackRef, onPointerDown: (ev) => engineRef.current.beginDrag(ev) },
2456
+ h("canvas", { className: "es-rail", key: "rail", ref: railRef }),
2457
+ h(
2458
+ "div",
2459
+ { className: "es-ticks", key: "ticks" },
2460
+ snap.levels.map((lv, i) =>
2461
+ h("span", {
2462
+ key: "tick-" + lv.id,
2463
+ className: "es-tick",
2464
+ "data-on": i <= snap.index ? "1" : "0",
2465
+ style: { "--i": n > 1 ? (i / (n - 1)).toFixed(4) : "0" }
2466
+ })
2467
+ )
2468
+ ),
2469
+ h("div", { className: "es-thumb", key: "thumb" })
2470
+ ),
2471
+ h(
2472
+ "div",
2473
+ { className: "es-scale", key: "scale" },
2474
+ snap.levels.map((lv, i) =>
2475
+ h(
2476
+ "button",
2477
+ {
2478
+ key: "sc-" + lv.id,
2479
+ type: "button",
2480
+ className: "es-scale-item",
2481
+ "data-on": i === snap.index ? "1" : "0",
2482
+ style: { left: "calc(31px + (100% - 62px) * " + (n > 1 ? (i / (n - 1)).toFixed(4) : "0") + ")" },
2483
+ title: lv.description || lv.name,
2484
+ onClick: () => props.commit(i)
2485
+ },
2486
+ lv.name
2487
+ )
2488
+ )
2489
+ ),
2490
+ h(
2491
+ "div",
2492
+ { className: "es-opt", key: "opt" },
2493
+ h("span", { key: "l" }, props.t("haloLabel")),
2494
+ h(
2495
+ "button",
2496
+ {
2497
+ type: "button",
2498
+ role: "switch",
2499
+ className: "es-switch",
2500
+ title: props.t("haloTitle"),
2501
+ "aria-label": props.t("haloLabel"),
2502
+ "aria-checked": snap.halo ? "true" : "false",
2503
+ "data-on": snap.halo ? "1" : "0",
2504
+ onClick: () => props.toggleHalo()
2505
+ },
2506
+ h("span", { className: "es-switch-knob", key: "k" })
2507
+ )
2508
+ ),
2509
+ h("div", { className: "es-foot", key: "foot", "data-err": snap.error ? "1" : "0", "data-note": footNote ? "1" : "0" }, footText)
2510
+ ),
2511
+ h("canvas", { className: "es-fx", key: "fx", ref: fxRef }),
2512
+ h("div", { className: "es-flash", key: "flash", ref: flashRef }),
2513
+ h("div", { className: "es-big", key: "big", ref: bigRef })
2514
+ );
2515
+
2516
+ /* The popup is portalled to <body>: the composer row can sit inside a
2517
+ transformed or overflow-clipped ancestor, which would otherwise move
2518
+ or cut a position:fixed child. */
2519
+ const portal =
2520
+ reactDom && typeof reactDom.createPortal === "function" && typeof document !== "undefined"
2521
+ ? reactDom.createPortal(menu, document.body)
2522
+ : menu;
2523
+
2524
+ return h("span", { className: "es-anchor", key: "anchor" }, trigger, portal);
2525
+ }
2526
+
2527
+ /* ------------------------------------------------------------------ *
2528
+ * module surface
2529
+ * ------------------------------------------------------------------ */
2530
+
2531
+ /**
2532
+ * BOTH `remote` and `remote.session` are required: Cordis validates the
2533
+ * nested property access (`ctx.get("remote").session`) against this list, and
2534
+ * a missing entry throws `cannot get property "remote.session" without
2535
+ * inject` — which, from a render path, blanks the panel.
2536
+ */
2537
+ const inject = ["slots", "locale", "sessions", "remote", "remote.session"];
2538
+
2539
+ function apply(ctx) {
2540
+ injectCss();
2541
+
2542
+ ctx.effect(
2543
+ () => {
2544
+ try {
2545
+ return ctx.locale.register(NS, { zh, en });
2546
+ } catch (err) {
2547
+ return () => {};
2548
+ }
2549
+ },
2550
+ "effort-slider: dictionaries"
2551
+ );
2552
+
2553
+ const controller = new EffortController(ctx);
2554
+ controller.bindLocale();
2555
+
2556
+ ctx.effect(() => () => controller.dispose(), "effort-slider: controller");
2557
+
2558
+ /* Take over the shipped model cell so model + reasoning effort are one
2559
+ control. A single slot renders the LOWEST priority first
2560
+ (`entries` is sorted ascending by priority, and the renderer takes
2561
+ `entriesOfSlot(key)[0]`), so the shipped occupant (priority 0) is
2562
+ shadowed only by a negative priority. */
2563
+ ctx.slots.inject("conversation.input.model", () => {
2564
+ try {
2565
+ const unregister = ctx.slots.register(
2566
+ {
2567
+ name: "conversation.input.model",
2568
+ priority: -1,
2569
+ label: () => "Model and reasoning effort",
2570
+ inject: () => controller.face()
2571
+ },
2572
+ ModelEffortControl
2573
+ );
2574
+ return () => {
2575
+ unregister();
2576
+ };
2577
+ } catch (err) {
2578
+ return () => {};
2579
+ }
2580
+ });
2581
+ }
2582
+
2583
+ exports.name = "@lolannn/dsh-effort-slider";
2584
+ exports.inject = inject;
2585
+ exports.apply = apply;
2586
+
2587
+ return module.exports;
2588
+ }
2589
+ });