@lolannn/dsh-effort-slider 0.2.5 → 0.2.6

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 +2363 -2244
  3. package/package.json +1 -1
package/client.js CHANGED
@@ -1,2244 +1,2363 @@
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
+ 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
+ heat: 0,
431
+ warpFrom: -1,
432
+ warpFired: false,
433
+ topFired: false,
434
+ reduce: false,
435
+ els: null,
436
+ bgc: null,
437
+ rxc: null,
438
+ fxc: null,
439
+ W: 0,
440
+ H: 0,
441
+ dpr: 1,
442
+ rw: 0,
443
+ rh: 0,
444
+ rdpr: 1,
445
+ stars: [],
446
+ waves: [],
447
+ debris: [],
448
+ streaks: [],
449
+ astra: [],
450
+ neb: null,
451
+ parts: [],
452
+ partKey: "",
453
+ partKind: "",
454
+ partFade: 1,
455
+ prevParts: [],
456
+ prevKind: "",
457
+ prevFade: 0,
458
+ texts: { boom: "宇宙毁灭", after: "星际探索" },
459
+ onLevel: null,
460
+ onRelease: null,
461
+ bigTimer: 0,
462
+ warpTimer: 0
463
+ };
464
+
465
+ function topIndex() {
466
+ return topIndexOf(S.count);
467
+ }
468
+
469
+ /* ---------------- sizing ---------------- */
470
+
471
+ function fit() {
472
+ const els = S.els;
473
+ if (!els || !els.bg || !els.track) return;
474
+ S.dpr = Math.min(window.devicePixelRatio || 1, 2);
475
+ /* Both canvases live inside the popup, so they must match the popup's
476
+ own box — sizing them to the viewport would put the starfield centre
477
+ far outside the card (and draw a full screen of pixels per frame). */
478
+ let box = null;
479
+ if (els.stage && typeof els.stage.getBoundingClientRect === "function") {
480
+ const r = els.stage.getBoundingClientRect();
481
+ if (r.width > 0.5 && r.height > 0.5) box = r;
482
+ }
483
+ S.W = Math.max(1, box ? box.width : window.innerWidth);
484
+ S.H = Math.max(1, box ? box.height : window.innerHeight);
485
+ const prep = (cv) => {
486
+ if (!cv) return null;
487
+ cv.width = Math.max(1, Math.round(S.W * S.dpr));
488
+ cv.height = Math.max(1, Math.round(S.H * S.dpr));
489
+ cv.style.width = S.W + "px";
490
+ cv.style.height = S.H + "px";
491
+ const c = cv.getContext("2d");
492
+ if (c) c.setTransform(S.dpr, 0, 0, S.dpr, 0, 0);
493
+ return c;
494
+ };
495
+ S.bgc = prep(els.bg);
496
+ S.fxc = prep(els.fx);
497
+ const r = els.track.getBoundingClientRect();
498
+ S.rw = Math.max(1, r.width);
499
+ S.rh = Math.max(1, r.height);
500
+ S.rdpr = Math.min(window.devicePixelRatio || 1, 2);
501
+ if (els.rail) {
502
+ els.rail.width = Math.max(1, Math.round(S.rw * S.rdpr));
503
+ els.rail.height = Math.max(1, Math.round(S.rh * S.rdpr));
504
+ const rc = els.rail.getContext("2d");
505
+ if (rc) rc.setTransform(S.rdpr, 0, 0, S.rdpr, 0, 0);
506
+ S.rxc = rc;
507
+ }
508
+ }
509
+
510
+ /* ---------------- starfield state ---------------- */
511
+
512
+ function respawnStar(s) {
513
+ s.x = Math.random() * 2 - 1;
514
+ s.y = Math.random() * 2 - 1;
515
+ s.z = 0.98;
516
+ s.born = S.time;
517
+ s.life = S.warp > 0.5 ? 6 + Math.random() * 5.5 : 4.2 + Math.random() * 4.2;
518
+ }
519
+
520
+ function initStars() {
521
+ S.stars = [];
522
+ for (let i = 0; i < 260; i++) {
523
+ const s = {
524
+ x: Math.random() * 2 - 1,
525
+ y: Math.random() * 2 - 1,
526
+ z: 0.08 + Math.random() * 0.92,
527
+ c: Math.random() < 0.22 ? 1 : 0,
528
+ born: 0,
529
+ life: 4.2 + Math.random() * 4.2
530
+ };
531
+ S.stars.push(s);
532
+ }
533
+ }
534
+
535
+ /* ---------------- rail particles ---------------- */
536
+
537
+ function buildParticles(n, kind) {
538
+ const key = kind + ":" + n;
539
+ if (key === S.partKey) return;
540
+ S.prevParts = S.parts;
541
+ S.prevKind = S.partKind;
542
+ S.prevFade = S.partFade;
543
+ S.partKey = key;
544
+ S.partKind = kind;
545
+ S.partFade = 0;
546
+ S.parts = [];
547
+ if (n <= 0) return;
548
+ const rnd = mulberry32(hashStr(key));
549
+ for (let i = 0; i < n; i++) {
550
+ S.parts.push({
551
+ p0: rnd(),
552
+ y: 0.1 + rnd() * 0.8,
553
+ sp: 0.016 + rnd() * 0.058,
554
+ size: 0.55 + rnd() * 1.5,
555
+ hueOff: rnd() * 44 - 22,
556
+ tw: rnd() * Math.PI * 2
557
+ });
558
+ }
559
+ }
560
+
561
+ /**
562
+ * Visual spec for the rail. At or above the warp gate the rail renders
563
+ * the galaxy tier — the same swirl the top level shows — and keeps it for
564
+ * as long as the dial stays there.
565
+ */
566
+ function railSpec() {
567
+ const spec = specAt(S.t);
568
+ if (S.heat > 0.01 && spec.kind !== "none") {
569
+ spec.kind = "galaxy";
570
+ spec.n = Math.max(spec.n, 38);
571
+ spec.ribbon = false;
572
+ }
573
+ return spec;
574
+ }
575
+
576
+ function ensureParticles() {
577
+ const spec = railSpec();
578
+ buildParticles(spec.n, spec.kind);
579
+ }
580
+
581
+ function drawParticleSet(c, list, kind, mul, hue, e, pad, reach, ih) {
582
+ if (!list || !list.length || mul <= 0.002) return;
583
+ const starMode = kind === "star" || kind === "starHeavy" || kind === "galaxy";
584
+ const trailMode = starMode || kind === "trail";
585
+ const speedK = 0.55 + e * 1.7;
586
+ const base = kind === "dot" ? 0.9 : kind === "trail" ? 1.6 : 2.3;
587
+ const copies = starMode ? (kind === "star" ? 2 : 4) : 4;
588
+ const len = starMode ? (kind === "star" ? 15 : 10) : 10;
589
+ for (let i = 0; i < list.length; i++) {
590
+ const q = list[i];
591
+ let p = (q.p0 - S.time * q.sp * speedK) % 1;
592
+ if (p < 0) p += 1;
593
+ const x = pad + reach * p;
594
+ let y = pad + ih * q.y;
595
+ if (kind === "dot") y += Math.sin(q.tw + S.time * 1.3) * 1.2;
596
+ const flick = 0.62 + 0.38 * Math.sin(q.tw + S.time * 2.6);
597
+ const a = (0.2 + e * 0.62) * flick * mul;
598
+ if (a <= 0.004) continue;
599
+ const sz = base * (0.7 + q.size * 0.55);
600
+ if (trailMode) {
601
+ for (let k = 1; k <= copies; k++) {
602
+ const tx = x + len * k;
603
+ if (tx > pad + reach) break;
604
+ const ak = a * (1 - k / (copies + 1)) * 0.55;
605
+ if (starMode) star4(c, tx, y, sz * (1 - k * 0.08), ak * 0.75, hue + q.hueOff + 20, false);
606
+ else {
607
+ c.fillStyle = "hsla(" + (hue + q.hueOff) + ",100%,78%," + ak + ")";
608
+ c.beginPath();
609
+ c.arc(tx, y, Math.max(0.3, sz * 0.8), 0, Math.PI * 2);
610
+ c.fill();
611
+ }
612
+ }
613
+ }
614
+ if (starMode) star4(c, x, y, sz * (kind === "starHeavy" ? 1.4 : 1), a, hue + q.hueOff + 30, true);
615
+ else {
616
+ c.fillStyle = "hsla(" + (hue + q.hueOff) + ",100%,80%," + a + ")";
617
+ c.beginPath();
618
+ c.arc(x, y, Math.max(0.3, sz * (0.7 + e * 0.8)), 0, Math.PI * 2);
619
+ c.fill();
620
+ }
621
+ }
622
+ }
623
+
624
+ /* ---------------- background ---------------- */
625
+
626
+ function drawBG(dt) {
627
+ const c = S.bgc;
628
+ if (!c) return;
629
+ const W = S.W;
630
+ const H = S.H;
631
+ const e = S.e;
632
+ const hue = hueAtT(S.t);
633
+ c.setTransform(S.dpr, 0, 0, S.dpr, 0, 0);
634
+ c.globalCompositeOperation = "source-over";
635
+ c.clearRect(0, 0, W, H);
636
+ const g = c.createRadialGradient(W * 0.5, H * 0.45, 0, W * 0.5, H * 0.45, Math.max(W, H) * 0.85);
637
+ g.addColorStop(0, "hsla(" + (222 + e * 60) + "," + (46 + e * 26) + "%," + (5 + e * 4) + "%,.94)");
638
+ g.addColorStop(1, "rgba(3,4,10,.97)");
639
+ c.fillStyle = g;
640
+ c.fillRect(0, 0, W, H);
641
+
642
+ c.globalCompositeOperation = "lighter";
643
+
644
+ if (S.neb) {
645
+ const sway = Math.sin(S.time * 0.09) * 40;
646
+ const layers = [
647
+ [S.neb.a, 0.12 + e * 0.3, 0.3, sway],
648
+ [S.neb.b, 0.1 + e * 0.26, 0.72, -sway * 0.7],
649
+ [S.neb.c, 0.08 + e * 0.22, 0.5, sway * 0.45]
650
+ ];
651
+ for (let i = 0; i < layers.length; i++) {
652
+ const spr = layers[i][0];
653
+ if (!spr) continue;
654
+ const w = W * 1.5;
655
+ const hh = H * 1.5;
656
+ c.globalAlpha = Math.min(1, layers[i][1]);
657
+ c.drawImage(spr, W * layers[i][2] - w / 2 + layers[i][3], H * 0.5 - hh / 2 + layers[i][3] * 0.4, w, hh);
658
+ }
659
+ c.globalAlpha = 1;
660
+ }
661
+
662
+ const speed = 0.045 + e * 0.55 + S.warp * 2.4;
663
+ const fov = Math.min(W, H) * 0.62;
664
+ const cx = W * 0.5;
665
+ const cy = H * 0.46;
666
+ for (let i = 0; i < S.stars.length; i++) {
667
+ const s = S.stars[i];
668
+ s.z -= dt * speed * (0.35 + (1 - s.z) * 1.6);
669
+ const age = S.time - s.born;
670
+ if (s.z <= 0.012 || age > s.life) {
671
+ respawnStar(s);
672
+ continue;
673
+ }
674
+ const k = fov / s.z;
675
+ const near = 1 - s.z;
676
+ const x = cx + s.x * k;
677
+ const y = cy + s.y * k;
678
+ if (x < -80 || x > W + 80 || y < -80 || y > H + 80) {
679
+ respawnStar(s);
680
+ continue;
681
+ }
682
+ const lifeA = Math.min(1, age / 0.7) * Math.max(0, 1 - age / s.life);
683
+ const a = Math.min(1, near * 1.5) * (0.35 + e * 0.6) * lifeA;
684
+ if (a <= 0.004) continue;
685
+ const tint = s.c ? 268 : 196;
686
+ if (S.warp > 0.04 && near > 0.22) {
687
+ const pz = Math.max(0.02, s.z + dt * speed * 1.6);
688
+ const pk = fov / pz;
689
+ c.strokeStyle = "hsla(" + tint + ",100%,82%," + a * 0.85 + ")";
690
+ c.lineWidth = Math.min(3.2, 0.5 + near * 2.4);
691
+ c.beginPath();
692
+ c.moveTo(x, y);
693
+ c.lineTo(cx + s.x * pk, cy + s.y * pk);
694
+ c.stroke();
695
+ } else {
696
+ c.fillStyle = "hsla(" + tint + ",100%,88%," + a + ")";
697
+ c.beginPath();
698
+ c.arc(x, y, Math.min(2.4, 0.35 + near * 1.9), 0, Math.PI * 2);
699
+ c.fill();
700
+ }
701
+ }
702
+
703
+ for (let i = S.waves.length - 1; i >= 0; i--) {
704
+ const w = S.waves[i];
705
+ w.r += dt * (900 + 900 * w.a);
706
+ w.a *= Math.pow(0.18, dt);
707
+ if (w.a < 0.012) {
708
+ S.waves.splice(i, 1);
709
+ continue;
710
+ }
711
+ c.strokeStyle = "hsla(" + w.hue + ",100%,76%," + w.a + ")";
712
+ c.lineWidth = w.w * Math.max(0.2, w.a);
713
+ c.beginPath();
714
+ c.arc(cx, cy, w.r, 0, Math.PI * 2);
715
+ c.stroke();
716
+ }
717
+
718
+ for (let i = S.debris.length - 1; i >= 0; i--) {
719
+ const d = S.debris[i];
720
+ d.life -= dt * 0.42;
721
+ if (d.life <= 0) {
722
+ S.debris.splice(i, 1);
723
+ continue;
724
+ }
725
+ d.vy += 120 * dt;
726
+ d.vx *= Math.pow(0.55, dt);
727
+ d.x += d.vx * dt;
728
+ d.y += d.vy * dt;
729
+ const a = Math.min(1, d.life) * 0.9;
730
+ c.fillStyle = "hsla(" + d.hue + ",100%,82%," + a + ")";
731
+ c.beginPath();
732
+ c.arc(d.x, d.y, d.size * Math.max(0.3, d.life), 0, Math.PI * 2);
733
+ c.fill();
734
+ }
735
+
736
+ for (let i = S.streaks.length - 1; i >= 0; i--) {
737
+ const s = S.streaks[i];
738
+ s.life -= dt * 1.9;
739
+ if (s.life <= 0) {
740
+ S.streaks.splice(i, 1);
741
+ continue;
742
+ }
743
+ s.x += s.vx * dt;
744
+ s.y += s.vy * dt;
745
+ const a = Math.min(1, s.life) * 0.75;
746
+ c.strokeStyle = "hsla(" + s.hue + ",100%,86%," + a + ")";
747
+ c.lineWidth = 1.4;
748
+ c.beginPath();
749
+ c.moveTo(s.x, s.y);
750
+ c.lineTo(s.x - s.vx * 0.055, s.y - s.vy * 0.055);
751
+ c.stroke();
752
+ }
753
+
754
+ c.globalCompositeOperation = "source-over";
755
+ }
756
+
757
+ /* ---------------- the rail ---------------- */
758
+
759
+ function drawRail() {
760
+ const c = S.rxc;
761
+ if (!c) return;
762
+ const w = S.rw;
763
+ const hh = S.rh;
764
+ c.setTransform(S.rdpr, 0, 0, S.rdpr, 0, 0);
765
+ c.clearRect(0, 0, w, hh);
766
+ const spec = railSpec();
767
+ const e = S.e;
768
+ const hue = hueAtT(S.t);
769
+ const pad = 11;
770
+ const iw = Math.max(1, w - pad * 2);
771
+ const ih = Math.max(1, hh - pad * 2);
772
+ const tx = EDGE + (w - EDGE * 2) * S.t;
773
+ const reach = Math.max(1, tx - pad);
774
+ const cyy = hh * 0.5;
775
+
776
+ c.save();
777
+ roundRect(c, pad, pad, iw, ih, ih / 2);
778
+ c.clip();
779
+
780
+ const bgGrad = c.createLinearGradient(pad, 0, pad + iw, 0);
781
+ bgGrad.addColorStop(0, "hsla(" + (hue - 42) + ",72%," + (8 + e * 4) + "%,.96)");
782
+ bgGrad.addColorStop(0.5, "hsla(" + hue + ",72%," + (10 + e * 6) + "%,.96)");
783
+ bgGrad.addColorStop(1, "hsla(" + (hue + 46) + ",72%," + (9 + e * 5) + "%,.96)");
784
+ c.fillStyle = bgGrad;
785
+ c.fillRect(pad, pad, iw, ih);
786
+
787
+ const fillGrad = c.createLinearGradient(pad, 0, pad + Math.max(1, reach), 0);
788
+ fillGrad.addColorStop(0, "hsla(" + (hue - 34) + ",100%,62%,.55)");
789
+ fillGrad.addColorStop(0.45, "hsla(" + hue + ",100%,66%,.72)");
790
+ fillGrad.addColorStop(0.8, "hsla(" + (hue + 40) + ",100%,68%,.82)");
791
+ fillGrad.addColorStop(1, "hsla(" + (hue + 64) + ",100%,74%,.92)");
792
+ c.fillStyle = fillGrad;
793
+ c.fillRect(pad, pad, reach, ih);
794
+
795
+ c.globalCompositeOperation = "lighter";
796
+
797
+ if (spec.ribbon) {
798
+ const slow = S.time * (0.28 + e * 0.5);
799
+ for (let k = 0; k < 3; k++) {
800
+ const amp = (3 + k * 3.4) * (0.35 + e);
801
+ c.beginPath();
802
+ for (let i = 0; i <= 60; i++) {
803
+ const p = i / 60;
804
+ const x = pad + iw * p;
805
+ const y =
806
+ cyy +
807
+ Math.sin(p * (5.2 + k * 1.7) + slow * (1 + k * 0.35) + k) * amp +
808
+ Math.sin(p * 12 + slow * 1.7) * amp * 0.28;
809
+ if (i === 0) c.moveTo(x, y);
810
+ else c.lineTo(x, y);
811
+ }
812
+ c.strokeStyle = "hsla(" + (hue + 30 + k * 18) + ",100%,78%," + (0.1 + e * 0.26) + ")";
813
+ c.lineWidth = 1.1 + e * 1.5;
814
+ c.stroke();
815
+ }
816
+ }
817
+
818
+ c.save();
819
+ c.beginPath();
820
+ c.rect(pad, pad, reach, ih);
821
+ c.clip();
822
+ if (S.prevParts.length && S.prevFade > 0.01) drawParticleSet(c, S.prevParts, S.prevKind, S.prevFade, hue, e, pad, reach, ih);
823
+ drawParticleSet(c, S.parts, S.partKind, S.partFade, hue, e, pad, reach, ih);
824
+
825
+ if (spec.kind === "starHeavy") {
826
+ const sc = 0.6 + e * 0.7;
827
+ const br = 0.5 + 0.5 * Math.sin(S.time * 2.4);
828
+ const cxx = pad + iw * 0.5;
829
+ const halo = c.createRadialGradient(cxx, cyy, 0, cxx, cyy, 26 * sc);
830
+ halo.addColorStop(0, "hsla(" + hue + ",100%,90%," + (0.22 + br * 0.3) + ")");
831
+ halo.addColorStop(1, "hsla(" + hue + ",100%,70%,0)");
832
+ c.fillStyle = halo;
833
+ c.beginPath();
834
+ c.arc(cxx, cyy, 26 * sc, 0, Math.PI * 2);
835
+ c.fill();
836
+ star4(c, cxx, cyy, (13 + br * 3) * sc, 0.75 + br * 0.2, hue, true);
837
+ star4(c, cxx - iw * 0.17, cyy, 7 * sc, 0.42, hue + 40, false);
838
+ star4(c, cxx + iw * 0.17, cyy, 7 * sc, 0.42, hue - 30, false);
839
+ }
840
+
841
+ if (spec.kind === "galaxy") {
842
+ const ga = clamp01(S.heat);
843
+ /* Centred on what is actually filled, so the swirl is whole when the
844
+ * gate is high rather than half-clipped at the fill edge. */
845
+ const gw = Math.max(24, reach);
846
+ const cxx = pad + gw * 0.5;
847
+ const gr = gw * 0.24;
848
+ const core = c.createRadialGradient(cxx, cyy, 0, cxx, cyy, gr);
849
+ core.addColorStop(0, "rgba(255,255,255,.95)");
850
+ core.addColorStop(0.35, "hsla(196,100%,82%,.75)");
851
+ core.addColorStop(0.72, "hsla(258,100%,62%,.34)");
852
+ core.addColorStop(1, "hsla(258,100%,60%,0)");
853
+ c.fillStyle = core;
854
+ c.beginPath();
855
+ c.arc(cxx, cyy, gr, 0, Math.PI * 2);
856
+ c.fill();
857
+ for (let a = 0; a < 3; a++) {
858
+ for (let i = 0; i < 130; i++) {
859
+ const f = i / 130;
860
+ const ang = a * ((Math.PI * 2) / 3) + f * 5.4 + S.time * 0.55;
861
+ const rad = f * gw * 0.46;
862
+ const x = cxx + Math.cos(ang) * rad;
863
+ const y = cyy + Math.sin(ang) * rad * 0.42;
864
+ const alpha = (1 - f) * 0.5 * (0.4 + ga * 0.6);
865
+ c.fillStyle = "hsla(" + (196 + a * 32) + ",100%," + (86 - f * 18) + "%," + alpha + ")";
866
+ c.beginPath();
867
+ c.arc(x, y, 0.5 + (1 - f) * 1.5, 0, Math.PI * 2);
868
+ c.fill();
869
+ }
870
+ }
871
+ }
872
+ c.restore();
873
+
874
+ const gloss = c.createLinearGradient(0, pad, 0, pad + ih);
875
+ gloss.addColorStop(0, "rgba(255,255,255,.16)");
876
+ gloss.addColorStop(0.35, "rgba(255,255,255,.05)");
877
+ gloss.addColorStop(0.55, "rgba(255,255,255,0)");
878
+ gloss.addColorStop(1, "rgba(0,0,0,.18)");
879
+ c.fillStyle = gloss;
880
+ c.fillRect(pad, pad, iw, ih);
881
+
882
+ c.globalCompositeOperation = "source-over";
883
+ c.restore();
884
+
885
+ c.strokeStyle = "hsla(" + hue + ",100%,72%," + (0.18 + e * 0.5) + ")";
886
+ c.lineWidth = 1.2;
887
+ c.shadowColor = "hsla(" + hue + ",100%,68%,.9)";
888
+ c.shadowBlur = 10 + e * 26;
889
+ roundRect(c, pad + 0.5, pad + 0.5, iw - 1, ih - 1, ih / 2);
890
+ c.stroke();
891
+ c.shadowBlur = 0;
892
+ }
893
+
894
+ /* ---------------- light blades ---------------- */
895
+
896
+ function drawFx(dt) {
897
+ const c = S.fxc;
898
+ if (!c) return;
899
+ c.setTransform(S.dpr, 0, 0, S.dpr, 0, 0);
900
+ c.clearRect(0, 0, S.W, S.H);
901
+ if (!S.astra.length || !S.els || !S.els.track) return;
902
+ const r = S.els.track.getBoundingClientRect();
903
+ const cx = r.left + r.width / 2;
904
+ const cy = r.top + r.height / 2;
905
+ const k = Math.min(1.25, 0.35 + (r.width / 900) * 0.9);
906
+ for (let i = S.astra.length - 1; i >= 0; i--) {
907
+ const f = S.astra[i];
908
+ f.age += dt;
909
+ const p = f.age / f.life;
910
+ if (p >= 1) {
911
+ S.astra.splice(i, 1);
912
+ continue;
913
+ }
914
+ const p2 = p * p;
915
+ const th = 13 * (1 - p2) * (1 - p2 * 0.35);
916
+ const baseRy = r.height * 0.55;
917
+ const halfL = r.width * 0.62;
918
+ for (let L = 0; L < 14; L++) {
919
+ const lt = L / 13;
920
+ const a = (0.022 + 0.95 * Math.pow(lt, 2.3)) * (1 - p2) * 0.8;
921
+ if (a <= 0.004) continue;
922
+ const col = Math.round(90 + 165 * Math.pow(lt, 0.7)) + "," + Math.round(150 + 105 * Math.pow(lt, 0.5)) + ",255";
923
+ c.fillStyle = "rgba(" + col + "," + a + ")";
924
+ const ry = Math.max(0.5, th * (0.4 + L * 0.06));
925
+ starBeam(c, cx, cy, halfL * (0.55 + L * 0.03), ry);
926
+ c.save();
927
+ c.translate(cx, cy);
928
+ c.rotate(Math.PI / 2);
929
+ c.translate(-cx, -cy);
930
+ starBeam(c, cx, cy, baseRy * (0.35 + L * 0.05), Math.max(0.4, ry * 0.45));
931
+ c.restore();
932
+ }
933
+ const cr = Math.max(1, (8 + 34 * (1 - p2)) * k);
934
+ const g = c.createRadialGradient(cx, cy, 0, cx, cy, cr);
935
+ g.addColorStop(0, "rgba(255,255,255," + 0.85 * (1 - p2) + ")");
936
+ g.addColorStop(0.5, "rgba(190,215,255," + 0.35 * (1 - p2) + ")");
937
+ g.addColorStop(1, "rgba(120,160,255,0)");
938
+ c.fillStyle = g;
939
+ c.beginPath();
940
+ c.arc(cx, cy, cr, 0, Math.PI * 2);
941
+ c.fill();
942
+ }
943
+ }
944
+
945
+ /* ---------------- celebration ---------------- */
946
+
947
+ function spawnStreaks() {
948
+ for (let i = 0; i < 24; i++) {
949
+ const ang = Math.random() * Math.PI * 2;
950
+ const sp = 380 + Math.random() * 980;
951
+ S.streaks.push({
952
+ x: S.W * 0.5,
953
+ y: S.H * 0.5,
954
+ vx: Math.cos(ang) * sp,
955
+ vy: Math.sin(ang) * sp,
956
+ life: 1,
957
+ hue: 180 + Math.random() * 150
958
+ });
959
+ }
960
+ }
961
+
962
+ /**
963
+ * Light celebration for crossing the warp gate at a level below the top.
964
+ *
965
+ * The full boom (quake, white flash, the big caption) stays reserved for
966
+ * the top level; this one only throws streaks, embers and two thin rings,
967
+ * so the rail lights up the moment the dial reaches high territory.
968
+ */
969
+ function surge() {
970
+ if (!S.els || S.reduce) return;
971
+ spawnStreaks();
972
+ S.waves.push({ r: 0, a: 0.72, w: 7, hue: 190 });
973
+ S.waves.push({ r: 0, a: 0.4, w: 18, hue: 300 });
974
+ for (let i = 0; i < 90; i++) {
975
+ const ang = Math.random() * Math.PI * 2;
976
+ const sp = 40 + Math.random() * 320;
977
+ S.debris.push({
978
+ x: S.W * 0.5,
979
+ y: S.H * 0.5,
980
+ vx: Math.cos(ang) * sp,
981
+ vy: Math.sin(ang) * sp,
982
+ life: 1,
983
+ hue: [190, 300, 45][i % 3],
984
+ size: 0.5 + Math.random() * 1.6
985
+ });
986
+ }
987
+ }
988
+
989
+ function boom() {
990
+ const els = S.els;
991
+ if (!els) return;
992
+ S.astra.push({ age: 0, life: 0.9 });
993
+ if (S.reduce) return;
994
+ if (els.stage && els.stage.classList) {
995
+ els.stage.classList.remove("es-quake");
996
+ void els.stage.offsetWidth;
997
+ els.stage.classList.add("es-quake");
998
+ }
999
+ if (els.flash && els.flash.classList) {
1000
+ els.flash.classList.remove("go");
1001
+ void els.flash.offsetWidth;
1002
+ els.flash.classList.add("go");
1003
+ }
1004
+ if (els.big && els.big.classList) {
1005
+ els.big.textContent = S.texts.boom;
1006
+ els.big.classList.remove("go");
1007
+ void els.big.offsetWidth;
1008
+ els.big.classList.add("go");
1009
+ clearTimeout(S.bigTimer);
1010
+ S.bigTimer = setTimeout(() => {
1011
+ if (els.big) els.big.textContent = S.texts.after;
1012
+ }, 2900);
1013
+ }
1014
+ const alphas = [0.95, 0.7, 0.5, 0.35];
1015
+ const widths = [6, 14, 26, 40];
1016
+ const hues = [190, 300, 46, 210];
1017
+ for (let i = 0; i < 4; i++) S.waves.push({ r: 0, a: alphas[i], w: widths[i], hue: hues[i] });
1018
+ for (let i = 0; i < 220; i++) {
1019
+ const ang = Math.random() * Math.PI * 2;
1020
+ const sp = 60 + Math.random() * 620;
1021
+ S.debris.push({
1022
+ x: S.W * 0.5,
1023
+ y: S.H * 0.5,
1024
+ vx: Math.cos(ang) * sp,
1025
+ vy: Math.sin(ang) * sp,
1026
+ life: 1,
1027
+ hue: [190, 300, 45][i % 3],
1028
+ size: 0.6 + Math.random() * 2.2
1029
+ });
1030
+ }
1031
+ clearTimeout(S.warpTimer);
1032
+ S.warpTimer = setTimeout(() => {
1033
+ S.warp = 1;
1034
+ }, 220);
1035
+ }
1036
+
1037
+ /* ---------------- loop ---------------- */
1038
+
1039
+ function syncLevel() {
1040
+ const top = topIndex();
1041
+ const i = top > 0 ? Math.round(S.t * top) : 0;
1042
+ if (i !== S.idx) {
1043
+ S.idx = i;
1044
+ if (S.onLevel) {
1045
+ try {
1046
+ S.onLevel(i);
1047
+ } catch (err) {
1048
+ /* a listener error must not stop the animation */
1049
+ }
1050
+ }
1051
+ }
1052
+ /*
1053
+ * The starfield goes to warp from the "gate" level upward, not only at
1054
+ * the very top: the gate is the index of the level named "high" (see
1055
+ * warpIndexOf), so moving into high — or anything above it — lights the
1056
+ * rail and keeps it lit until the slider drops back below.
1057
+ */
1058
+ const gate = S.warpFrom >= 0 ? Math.min(S.warpFrom, top) : top;
1059
+ const inside = top > 0 && i >= gate;
1060
+ if (inside && !S.warpFired) {
1061
+ S.warpFired = true;
1062
+ S.warp = 1;
1063
+ if (i < top) surge();
1064
+ } else if (!inside) {
1065
+ S.warpFired = false;
1066
+ if (S.warp > 0.01) S.warp = 0;
1067
+ }
1068
+ if (i < top) {
1069
+ S.topFired = false;
1070
+ } else if (i === top && !S.topFired) {
1071
+ S.topFired = true;
1072
+ boom();
1073
+ }
1074
+ }
1075
+
1076
+ function paintDom() {
1077
+ const el = S.els && S.els.stage;
1078
+ if (!el || !el.style) return;
1079
+ el.style.setProperty("--es-t", S.t.toFixed(4));
1080
+ el.style.setProperty("--es-hue", String(Math.round(hueAtT(S.t))));
1081
+ el.style.setProperty("--es-e", S.e.toFixed(3));
1082
+ }
1083
+
1084
+ function loop(now) {
1085
+ if (!S.mounted) return;
1086
+ S.raf = requestAnimationFrame(loop);
1087
+ const dt = Math.min(0.05, S.last ? (now - S.last) / 1000 : 0.016);
1088
+ S.last = now;
1089
+ S.time += dt;
1090
+ S.frameNo++;
1091
+
1092
+ if (S.anim) {
1093
+ const p = clamp01((now - S.anim.start) / S.anim.dur);
1094
+ S.t = clamp01(S.anim.from + (S.anim.to - S.anim.from) * easeOutBack(p));
1095
+ if (p >= 1) {
1096
+ S.t = S.anim.to;
1097
+ S.anim = null;
1098
+ }
1099
+ }
1100
+
1101
+ S.e += (S.t - S.e) * Math.min(1, dt * (S.dragging ? 14 : 6));
1102
+ S.partFade = Math.min(1, S.partFade + dt / 0.28);
1103
+ if (S.prevFade > 0) S.prevFade = Math.max(0, S.prevFade - dt / 0.24);
1104
+
1105
+ syncLevel();
1106
+
1107
+ /* 0 outside the warp gate, 1 inside it, eased so the galaxy on the rail
1108
+ * fades in instead of popping when the dial crosses high. */
1109
+ const heatTarget = S.warpFired ? 1 : 0;
1110
+ S.heat += (heatTarget - S.heat) * Math.min(1, dt * 7);
1111
+ if (Math.abs(heatTarget - S.heat) < 0.002) S.heat = heatTarget;
1112
+
1113
+ ensureParticles();
1114
+
1115
+ const top = topIndex();
1116
+ const gate = S.warpFrom >= 0 ? Math.min(S.warpFrom, top) : top;
1117
+ if (!S.reduce && top > 0 && S.idx >= gate && S.frameNo % 10 === 0) spawnStreaks();
1118
+
1119
+ drawBG(dt);
1120
+ drawRail();
1121
+ drawFx(dt);
1122
+ paintDom();
1123
+ }
1124
+
1125
+ /* ---------------- pointer / keyboard ---------------- */
1126
+
1127
+ function tFromEvent(ev) {
1128
+ const el = S.els && S.els.track;
1129
+ if (!el) return S.t;
1130
+ const r = el.getBoundingClientRect();
1131
+ const span = r.width - EDGE * 2;
1132
+ if (span <= 1) return 0;
1133
+ const x = clamp(ev.clientX - r.left, EDGE, r.width - EDGE);
1134
+ return clamp01((x - EDGE) / span);
1135
+ }
1136
+
1137
+ function beginDrag(ev) {
1138
+ if (!S.mounted) return;
1139
+ S.dragging = true;
1140
+ S.anim = null;
1141
+ S.t = tFromEvent(ev);
1142
+ try {
1143
+ if (ev.currentTarget && ev.currentTarget.setPointerCapture) ev.currentTarget.setPointerCapture(ev.pointerId);
1144
+ } catch (err) {
1145
+ /* capture is a nicety */
1146
+ }
1147
+ ev.preventDefault();
1148
+ }
1149
+
1150
+ function onDocMove(ev) {
1151
+ if (!S.dragging) return;
1152
+ S.t = tFromEvent(ev);
1153
+ }
1154
+
1155
+ function onDocUp() {
1156
+ if (!S.dragging) return;
1157
+ S.dragging = false;
1158
+ snapTo(levelT(S.idx, S.count), 300);
1159
+ if (S.onRelease) {
1160
+ try {
1161
+ S.onRelease(S.idx);
1162
+ } catch (err) {
1163
+ /* ignore */
1164
+ }
1165
+ }
1166
+ }
1167
+
1168
+ function onKey(ev) {
1169
+ if (!S.mounted) return;
1170
+ /* The rail is now inline in the composer, so the caret may be in the
1171
+ prompt box while this listener is live. Never steal arrow keys from
1172
+ an editable target. */
1173
+ const target = ev.target;
1174
+ if (target && typeof target === "object") {
1175
+ const tag = String(target.tagName || "").toUpperCase();
1176
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable === true) return;
1177
+ }
1178
+ const top = topIndex();
1179
+ let next = null;
1180
+ if (ev.key === "ArrowRight" || ev.key === "ArrowUp") next = Math.min(top, S.idx + 1);
1181
+ else if (ev.key === "ArrowLeft" || ev.key === "ArrowDown") next = Math.max(0, S.idx - 1);
1182
+ else if (ev.key === "Home") next = 0;
1183
+ else if (ev.key === "End") next = top;
1184
+ if (next === null) return;
1185
+ ev.preventDefault();
1186
+ snapTo(levelT(next, S.count), ev.key === "Home" || ev.key === "End" ? 420 : 300);
1187
+ if (S.onRelease) {
1188
+ try {
1189
+ S.onRelease(next);
1190
+ } catch (err) {
1191
+ /* ignore */
1192
+ }
1193
+ }
1194
+ }
1195
+
1196
+ function snapTo(to, dur) {
1197
+ S.anim = { from: S.t, to: clamp01(to), start: performance.now(), dur: dur || 300 };
1198
+ }
1199
+
1200
+ /* ---------------- lifecycle ---------------- */
1201
+
1202
+ function mount(opts) {
1203
+ S.els = opts.els;
1204
+ S.onLevel = opts.onLevel;
1205
+ S.onRelease = opts.onRelease;
1206
+ S.texts = opts.texts || S.texts;
1207
+ S.reduce = !!opts.reduce;
1208
+ S.count = Math.max(1, (opts.count || 1) | 0);
1209
+ S.warpFrom = typeof opts.warpFrom === "number" ? opts.warpFrom | 0 : -1;
1210
+ S.mounted = true;
1211
+ S.time = 0;
1212
+ S.frameNo = 0;
1213
+ S.last = 0;
1214
+ S.e = S.t;
1215
+ S.topFired = false;
1216
+ S.warpFired = false;
1217
+ S.warp = 0;
1218
+ S.waves.length = 0;
1219
+ S.debris.length = 0;
1220
+ S.streaks.length = 0;
1221
+ S.astra.length = 0;
1222
+ S.parts = [];
1223
+ S.prevParts = [];
1224
+ S.partKey = "";
1225
+ S.partKind = "";
1226
+ S.partFade = 1;
1227
+ S.prevFade = 0;
1228
+ initStars();
1229
+ if (!S.neb) S.neb = { a: nebulaSprite(216, 262), b: nebulaSprite(286, 196), c: nebulaSprite(190, 320) };
1230
+ fit();
1231
+ requestAnimationFrame(() => {
1232
+ if (S.mounted) fit();
1233
+ });
1234
+ window.addEventListener("resize", fit);
1235
+ window.addEventListener("orientationchange", fit);
1236
+ /* The popup grows and shrinks as the model list opens and closes. */
1237
+ if (typeof ResizeObserver === "function" && S.els && S.els.stage) {
1238
+ S.ro = new ResizeObserver(() => {
1239
+ if (S.mounted) fit();
1240
+ });
1241
+ try {
1242
+ S.ro.observe(S.els.stage);
1243
+ } catch (err) {
1244
+ S.ro = null;
1245
+ }
1246
+ }
1247
+ window.addEventListener("pointermove", onDocMove);
1248
+ window.addEventListener("pointerup", onDocUp);
1249
+ window.addEventListener("pointercancel", onDocUp);
1250
+ window.addEventListener("keydown", onKey);
1251
+ S.raf = requestAnimationFrame(loop);
1252
+ }
1253
+
1254
+ function unmount() {
1255
+ S.mounted = false;
1256
+ if (S.raf) cancelAnimationFrame(S.raf);
1257
+ S.raf = 0;
1258
+ clearTimeout(S.bigTimer);
1259
+ clearTimeout(S.warpTimer);
1260
+ window.removeEventListener("resize", fit);
1261
+ window.removeEventListener("orientationchange", fit);
1262
+ if (S.ro) {
1263
+ try {
1264
+ S.ro.disconnect();
1265
+ } catch (err) {}
1266
+ S.ro = null;
1267
+ }
1268
+ window.removeEventListener("pointermove", onDocMove);
1269
+ window.removeEventListener("pointerup", onDocUp);
1270
+ window.removeEventListener("pointercancel", onDocUp);
1271
+ window.removeEventListener("keydown", onKey);
1272
+ S.els = null;
1273
+ S.bgc = null;
1274
+ S.rxc = null;
1275
+ S.fxc = null;
1276
+ }
1277
+
1278
+ function setCount(n) {
1279
+ const next = Math.max(1, n | 0);
1280
+ if (next === S.count) return;
1281
+ S.count = next;
1282
+ S.idx = Math.min(S.idx, topIndexOf(S.count));
1283
+ }
1284
+
1285
+ /** Move the warp gate when the model (and so its level list) changes. */
1286
+ function setWarpFrom(index) {
1287
+ const next = typeof index === "number" && index >= 0 ? index | 0 : -1;
1288
+ if (next === S.warpFrom) return;
1289
+ S.warpFrom = next;
1290
+ if (!S.mounted) return;
1291
+ const top = topIndex();
1292
+ const gate = S.warpFrom >= 0 ? Math.min(S.warpFrom, top) : top;
1293
+ if (S.idx < gate) {
1294
+ S.warpFired = false;
1295
+ if (S.warp > 0.01) S.warp = 0;
1296
+ }
1297
+ }
1298
+
1299
+ function setIndex(index, immediate) {
1300
+ const top = topIndex();
1301
+ const to = levelT(clamp(index, 0, top), S.count);
1302
+ if (immediate) {
1303
+ S.anim = null;
1304
+ S.t = to;
1305
+ S.e = to;
1306
+ S.idx = Math.round(to * top);
1307
+ /*
1308
+ * Opening the panel must not replay the celebration. Only a real move
1309
+ * into the warp gate (drag, click, keyboard) fires it; mounting with
1310
+ * the slider already at or above the gate just arms the guards. The
1311
+ * warp glow itself stays on either way: the state, not the burst, is
1312
+ * what marks "this model is running hot".
1313
+ */
1314
+ const gate = S.warpFrom >= 0 ? Math.min(S.warpFrom, top) : top;
1315
+ const inside = S.count > 1 && S.idx >= gate;
1316
+ S.topFired = S.count > 1 && S.idx === top;
1317
+ S.warpFired = inside;
1318
+ S.warp = inside ? 1 : 0;
1319
+ } else {
1320
+ snapTo(to, 320);
1321
+ }
1322
+ }
1323
+
1324
+ return {
1325
+ mount,
1326
+ unmount,
1327
+ setCount,
1328
+ setWarpFrom,
1329
+ setIndex,
1330
+ beginDrag,
1331
+ isDragging() {
1332
+ return S.dragging;
1333
+ },
1334
+ isMounted() {
1335
+ return S.mounted;
1336
+ },
1337
+ index() {
1338
+ return S.idx;
1339
+ }
1340
+ };
1341
+ }
1342
+
1343
+ /* ------------------------------------------------------------------ *
1344
+ * glyphs
1345
+ * ------------------------------------------------------------------ */
1346
+
1347
+ function boltGlyph(size, hue) {
1348
+ return h(
1349
+ "svg",
1350
+ { width: size, height: size, viewBox: "0 0 24 24", "aria-hidden": "true", style: { display: "block" } },
1351
+ h("path", {
1352
+ d: "M13.6 2 5.2 13.1h5.3L9.9 22l8.9-11.6h-5.6L13.6 2Z",
1353
+ fill: "currentColor",
1354
+ opacity: 0.92
1355
+ }),
1356
+ h("path", {
1357
+ d: "M13.6 2 5.2 13.1h5.3L9.9 22l8.9-11.6h-5.6L13.6 2Z",
1358
+ fill: "none",
1359
+ stroke: "hsl(" + hue + " 100% 78%)",
1360
+ strokeWidth: 0.7,
1361
+ opacity: 0.8
1362
+ })
1363
+ );
1364
+ }
1365
+
1366
+ function resetGlyph() {
1367
+ return h(
1368
+ "svg",
1369
+ { width: 13, height: 13, viewBox: "0 0 24 24", "aria-hidden": "true" },
1370
+ h("path", {
1371
+ d: "M4 12a8 8 0 1 0 2.5-5.8",
1372
+ fill: "none",
1373
+ stroke: "currentColor",
1374
+ strokeWidth: 2,
1375
+ strokeLinecap: "round"
1376
+ }),
1377
+ h("path", { d: "M4 4v5h5", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round" })
1378
+ );
1379
+ }
1380
+
1381
+ function closeGlyph() {
1382
+ return h(
1383
+ "svg",
1384
+ { width: 13, height: 13, viewBox: "0 0 24 24", "aria-hidden": "true" },
1385
+ h("path", { d: "M6 6l12 12M18 6L6 18", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round" })
1386
+ );
1387
+ }
1388
+
1389
+ /* ------------------------------------------------------------------ *
1390
+ * controller
1391
+ * ------------------------------------------------------------------ */
1392
+
1393
+ function findModel(catalog, sel) {
1394
+ if (!catalog || !sel) return void 0;
1395
+ const groups = Array.isArray(catalog.groups) ? catalog.groups : [];
1396
+ for (let i = 0; i < groups.length; i++) {
1397
+ const g = groups[i];
1398
+ const models = g && Array.isArray(g.models) ? g.models : [];
1399
+ for (let j = 0; j < models.length; j++) {
1400
+ const m = models[j];
1401
+ if (m && m.id === sel.model && (!sel.provider || g.id === sel.provider)) return m;
1402
+ }
1403
+ }
1404
+ for (let i = 0; i < groups.length; i++) {
1405
+ const models = groups[i] && Array.isArray(groups[i].models) ? groups[i].models : [];
1406
+ for (let j = 0; j < models.length; j++) if (models[j] && models[j].id === sel.model) return models[j];
1407
+ }
1408
+ return void 0;
1409
+ }
1410
+
1411
+ /*
1412
+ * Reasoning-effort ids form one shared scale even though providers declare
1413
+ * different subsets of it (off/low/high/max here, off/minimal/low/medium/
1414
+ * high/xhigh/max there). Ranking on that shared scale is what lets a level
1415
+ * the user already picked be mapped onto a model that lacks it.
1416
+ */
1417
+ const EFFORT_LADDER = ["off", "none", "minimal", "low", "medium", "med", "high", "xhigh", "max", "ultra"];
1418
+
1419
+ /** The effort levels one catalogue model declares, as plain objects. */
1420
+ function modelEfforts(model) {
1421
+ const reasoning = model && model.reasoning ? model.reasoning : void 0;
1422
+ const efforts = reasoning && Array.isArray(reasoning.efforts) ? reasoning.efforts : [];
1423
+ return efforts
1424
+ .filter((ef) => ef && ef.id !== void 0 && ef.id !== null)
1425
+ .map((ef) => ({
1426
+ id: String(ef.id),
1427
+ name: String(ef.name || ef.id),
1428
+ description: ef.description ? String(ef.description) : ""
1429
+ }));
1430
+ }
1431
+
1432
+ /**
1433
+ * Map the effort being carried onto the effort list of the model being
1434
+ * switched to.
1435
+ *
1436
+ * `{ id }` the carried level is supported as-is
1437
+ * `{ id, from }` the model lacks it and the closest rung was swapped in
1438
+ * `{ id: null, from }` the model declares no reasoning at all, so the
1439
+ * request must omit `reasoningEffort` entirely (the
1440
+ * Host rejects any explicit effort for such a model)
1441
+ */
1442
+ function resolveEffort(model, wantedId) {
1443
+ const levels = modelEfforts(model);
1444
+ const wanted = wantedId === void 0 || wantedId === null ? "" : String(wantedId);
1445
+ if (levels.length === 0) return { id: null, from: wanted };
1446
+ if (!wanted) return { id: null, from: "" };
1447
+ if (levels.some((lv) => lv.id === wanted)) return { id: wanted, from: wanted };
1448
+ const wantRank = EFFORT_LADDER.indexOf(wanted.toLowerCase());
1449
+ if (wantRank < 0) {
1450
+ /* A provider-specific name that is not on the shared scale: its
1451
+ distance is unknowable, so prefer this model's own default and only
1452
+ then its strongest rung. */
1453
+ const fallback = model.reasoning && model.reasoning.defaultEffort ? String(model.reasoning.defaultEffort) : "";
1454
+ if (fallback && levels.some((lv) => lv.id === fallback)) return { id: fallback, from: wanted };
1455
+ return { id: levels[levels.length - 1].id, from: wanted };
1456
+ }
1457
+ let best = 0;
1458
+ let bestGap = Infinity;
1459
+ let bestScaled = -Infinity;
1460
+ for (let i = 0; i < levels.length; i++) {
1461
+ const rank = EFFORT_LADDER.indexOf(levels[i].id.toLowerCase());
1462
+ /* An off-ladder rung still has to sit somewhere: spread the unknown
1463
+ ones evenly across the interior of the scale, in declared order. */
1464
+ const scaled = rank >= 0 ? rank : 1 + (levels.length > 1 ? (i / (levels.length - 1)) * (EFFORT_LADDER.length - 2) : 0);
1465
+ const gap = Math.abs(scaled - wantRank);
1466
+ /* Equal distance keeps the stronger rung — silently dropping reasoning
1467
+ power is the worse of the two surprises — and it is decided on the
1468
+ scale, not on declaration order, so a provider that lists its levels
1469
+ the other way round still gets the strong pick. */
1470
+ if (gap < bestGap - 1e-9 || (gap < bestGap + 1e-9 && scaled > bestScaled)) {
1471
+ bestGap = Math.min(gap, bestGap);
1472
+ bestScaled = scaled;
1473
+ best = i;
1474
+ }
1475
+ }
1476
+ return { id: levels[best].id, from: wanted };
1477
+ }
1478
+
1479
+ /** Display name of one effort id on a model, falling back to the id. */
1480
+ function effortName(model, id) {
1481
+ const hit = modelEfforts(model).filter((lv) => lv.id === id)[0];
1482
+ return hit ? hit.name : String(id);
1483
+ }
1484
+
1485
+ /**
1486
+ * Index of the level where the starfield jumps to warp.
1487
+ *
1488
+ * It is the level named "high" — from there upward the rail runs hot. A
1489
+ * model that declares no "high" gets the last level at or below high on the
1490
+ * shared effort ladder (a three-step off/low/max model starts warping at
1491
+ * low), and a model with a single level never warps at all.
1492
+ */
1493
+ function warpIndexOf(levels) {
1494
+ const list = Array.isArray(levels) ? levels : [];
1495
+ if (list.length <= 1) return 0;
1496
+ const named = list.findIndex((lv) => lv && lv.id === "high");
1497
+ if (named >= 0) return named;
1498
+ const gate = EFFORT_LADDER.indexOf("high");
1499
+ let best = 0;
1500
+ for (let i = 0; i < list.length; i++) {
1501
+ const rank = EFFORT_LADDER.indexOf(list[i] && list[i].id);
1502
+ if (rank >= 0 && rank <= gate) best = i;
1503
+ }
1504
+ return best;
1505
+ }
1506
+
1507
+ class EffortController {
1508
+ constructor(ctx) {
1509
+ this.ctx = ctx;
1510
+ this.catalog = null;
1511
+ this.selection = null;
1512
+ this.levels = [];
1513
+ this.defaultIndex = 0;
1514
+ this.sessionId = void 0;
1515
+ this.loading = false;
1516
+ this.commitTimer = 0;
1517
+ this.pendingIndex = -1;
1518
+ this.noticeTimer = 0;
1519
+ /* The last effort the user actually chose, kept so a round-trip through
1520
+ a model that cannot express it returns to that level, not a default. */
1521
+ this.lastEffort = "";
1522
+ this.token = 0;
1523
+ this.bindT = null;
1524
+ this.selFace = void 0;
1525
+ this.selUnsub = void 0;
1526
+ this.store = createStore({
1527
+ open: false,
1528
+ listOpen: false,
1529
+ levels: [],
1530
+ models: [],
1531
+ index: 0,
1532
+ t: 0,
1533
+ busy: false,
1534
+ error: null,
1535
+ notice: null,
1536
+ supported: true,
1537
+ ready: false,
1538
+ modelName: "",
1539
+ modelId: "",
1540
+ provider: ""
1541
+ });
1542
+ }
1543
+
1544
+ bindLocale() {
1545
+ const bind = readService(() => this.ctx.locale.bind(NS));
1546
+ if (typeof bind === "function") this.bindT = bind;
1547
+ }
1548
+
1549
+ t(key, vars) {
1550
+ let out = void 0;
1551
+ if (this.bindT) {
1552
+ try {
1553
+ const v = this.bindT(key, vars);
1554
+ if (typeof v === "string" && v.length && v !== key) out = v;
1555
+ } catch (err) {
1556
+ /* fall through to the bundled dictionary */
1557
+ }
1558
+ }
1559
+ if (out === void 0) {
1560
+ out = typeof zh[key] === "string" ? zh[key] : key;
1561
+ if (vars) {
1562
+ Object.keys(vars).forEach((k) => {
1563
+ out = out.split("{" + k + "}").join(String(vars[k]));
1564
+ });
1565
+ }
1566
+ }
1567
+ return out;
1568
+ }
1569
+
1570
+ face() {
1571
+ return {
1572
+ store: this.store,
1573
+ t: (k, v) => this.t(k, v),
1574
+ open: () => this.open(),
1575
+ close: () => this.close(),
1576
+ toggle: () => this.toggle(),
1577
+ toggleModels: () => this.toggleModels(),
1578
+ chooseModel: (provider, modelId) => this.chooseModel(provider, modelId),
1579
+ reset: () => this.reset(),
1580
+ commit: (i) => this.commit(i),
1581
+ setIndex: (i) => this.setIndex(i),
1582
+ setSession: (id) => this.setSession(id)
1583
+ };
1584
+ }
1585
+
1586
+ dispose() {
1587
+ clearTimeout(this.commitTimer);
1588
+ this.dropSelectionSubscription();
1589
+ }
1590
+
1591
+ /* ---------------- state ---------------- */
1592
+
1593
+ setIndex(index) {
1594
+ const snap = this.store.getSnapshot();
1595
+ const top = topIndexOf(snap.levels.length);
1596
+ const i = clamp(index | 0, 0, top);
1597
+ if (i === snap.index && Math.abs(snap.t - levelT(i, snap.levels.length)) < 0.001) return;
1598
+ this.store.set({ index: i, t: levelT(i, snap.levels.length) });
1599
+ }
1600
+
1601
+ setSession(id) {
1602
+ if (!id || id === this.sessionId) return;
1603
+ this.sessionId = id;
1604
+ /* Load the level names for the pill itself, so it shows the real effort
1605
+ instead of the generic label before the panel is ever opened. */
1606
+ this.refresh();
1607
+ }
1608
+
1609
+ /**
1610
+ * The Session's durable model-selection projection — the same source the
1611
+ * shipped selector reads. `projections.faceOf("modelSelection")` yields
1612
+ * `{ lastUsed, next }` where `next = pending ?? lastUsed`: a
1613
+ * `model/selection` event carries the user's intent immediately, and a
1614
+ * `request/header` records what a request actually used. `catalog.default`
1615
+ * is only the deployment default and can differ from what this Session
1616
+ * runs, so it is a fallback only.
1617
+ */
1618
+ selectionFace() {
1619
+ const sessionId = this.resolveSessionId();
1620
+ if (!sessionId) return void 0;
1621
+ const sessions = readService(() => this.ctx.sessions);
1622
+ if (!sessions || typeof sessions.binding !== "function") return void 0;
1623
+ let binding;
1624
+ try {
1625
+ binding = sessions.binding(sessionId);
1626
+ } catch (err) {
1627
+ return void 0;
1628
+ }
1629
+ const session = binding && binding.session;
1630
+ const projections = session && session.projections;
1631
+ if (!projections || typeof projections.faceOf !== "function") return void 0;
1632
+ try {
1633
+ return projections.faceOf("modelSelection");
1634
+ } catch (err) {
1635
+ return void 0;
1636
+ }
1637
+ }
1638
+
1639
+ /** Current projected selection, or undefined when the projection has no value yet. */
1640
+ projectedSelection() {
1641
+ const face = this.selectionFace();
1642
+ if (!face || typeof face.getSnapshot !== "function") return void 0;
1643
+ let view;
1644
+ try {
1645
+ view = face.getSnapshot();
1646
+ } catch (err) {
1647
+ return void 0;
1648
+ }
1649
+ const next = view && view.next;
1650
+ if (!next || typeof next.provider !== "string" || typeof next.model !== "string") return void 0;
1651
+ return next;
1652
+ }
1653
+
1654
+ dropSelectionSubscription() {
1655
+ if (this.selUnsub) {
1656
+ try {
1657
+ this.selUnsub();
1658
+ } catch (err) {
1659
+ /* an already-disposed face throws on unsubscribe */
1660
+ }
1661
+ }
1662
+ this.selUnsub = void 0;
1663
+ this.selFace = void 0;
1664
+ }
1665
+
1666
+ /**
1667
+ * Follow the Session's projection so a selection made anywhere else (the
1668
+ * `/model` command, a restored Session) updates this control instead of
1669
+ * leaving a stale label in the composer.
1670
+ */
1671
+ bindSelection() {
1672
+ const face = this.selectionFace();
1673
+ if (face === this.selFace) return;
1674
+ this.dropSelectionSubscription();
1675
+ if (!face || typeof face.subscribe !== "function") return;
1676
+ this.selFace = face;
1677
+ this.selUnsub = face.subscribe(() => {
1678
+ /* Opening the face and later commits both notify; defer so the
1679
+ in-flight refresh guard has cleared before this one starts. */
1680
+ setTimeout(() => {
1681
+ this.refresh();
1682
+ }, 0);
1683
+ });
1684
+ }
1685
+
1686
+ resolveSessionId() {
1687
+ if (this.sessionId) return this.sessionId;
1688
+ const sessions = readService(() => this.ctx.sessions);
1689
+ if (!sessions || !sessions.list || typeof sessions.list.getSnapshot !== "function") return void 0;
1690
+ let snap = void 0;
1691
+ try {
1692
+ snap = sessions.list.getSnapshot();
1693
+ } catch (err) {
1694
+ return void 0;
1695
+ }
1696
+ return mainViewSessionId(snap && snap.byId);
1697
+ }
1698
+
1699
+ open() {
1700
+ this.store.set({ open: true });
1701
+ this.refresh();
1702
+ }
1703
+
1704
+ close() {
1705
+ this.store.set({ open: false, listOpen: false });
1706
+ }
1707
+
1708
+ toggle() {
1709
+ if (this.store.getSnapshot().open) this.close();
1710
+ else this.open();
1711
+ }
1712
+
1713
+ toggleModels() {
1714
+ const snap = this.store.getSnapshot();
1715
+ this.store.set({ listOpen: !snap.listOpen });
1716
+ }
1717
+
1718
+ reset() {
1719
+ const target = this.defaultIndex;
1720
+ this.setIndex(target);
1721
+ this.commit(target);
1722
+ }
1723
+
1724
+ /**
1725
+ * Switch the Session-local model while keeping the reasoning effort the
1726
+ * user already picked, then reload the catalogue because the new model
1727
+ * exposes its own effort list.
1728
+ */
1729
+ async chooseModel(provider, modelId) {
1730
+ const snap = this.store.getSnapshot();
1731
+ if (snap.provider === provider && snap.modelId === modelId) {
1732
+ this.store.set({ listOpen: false });
1733
+ return;
1734
+ }
1735
+ const remote = readService(() => this.ctx.remote.session);
1736
+ if (!remote || typeof remote.selectModel !== "function") {
1737
+ this.store.set({ error: "selectModel is unavailable in this build" });
1738
+ return;
1739
+ }
1740
+ const sessionId = this.resolveSessionId();
1741
+ if (!sessionId) {
1742
+ this.store.set({ error: this.t("noSession") });
1743
+ return;
1744
+ }
1745
+ const level = this.levels[snap.index];
1746
+ if (level) this.lastEffort = level.id;
1747
+ const target = findModel(this.catalog, { provider: String(provider), model: String(modelId) });
1748
+ /* The Host rejects an explicit effort the target model does not declare
1749
+ (UNSUPPORTED_REASONING_EFFORT) and performs no clamping of its own, so
1750
+ the carried level is mapped onto the target's rungs before asking.
1751
+ An unknown target keeps the old shape and lets the Host be the
1752
+ authority. */
1753
+ const mapped = target ? resolveEffort(target, level ? level.id : this.lastEffort) : { id: level ? level.id : null, from: "" };
1754
+ const request = { sessionId, provider: String(provider), model: String(modelId) };
1755
+ if (mapped.id) request.reasoningEffort = mapped.id;
1756
+ const token = ++this.token;
1757
+ this.store.set({ busy: true, error: null, notice: null, listOpen: false });
1758
+ try {
1759
+ const res = await withTimeout(remote.selectModel(request), SELECT_TIMEOUT_MS);
1760
+ if (token !== this.token) return;
1761
+ if (res && res.ok) {
1762
+ const selected = res.value && res.value.selected;
1763
+ const next = Object.assign({}, this.selection || {}, selected || {}, {
1764
+ provider: request.provider,
1765
+ model: request.model
1766
+ });
1767
+ if (mapped.id) next.reasoningEffort = mapped.id;
1768
+ else delete next.reasoningEffort;
1769
+ this.selection = next;
1770
+ this.store.set({ busy: false, error: null, notice: this.note(this.switchNotice(target, level, mapped)) });
1771
+ this.loading = false;
1772
+ this.refresh();
1773
+ } else {
1774
+ this.store.set({ busy: false, error: messageOf(res && res.error) || "refused" });
1775
+ }
1776
+ } catch (err) {
1777
+ if (token !== this.token) return;
1778
+ this.store.set({ busy: false, error: messageOf(err) });
1779
+ }
1780
+ }
1781
+
1782
+ /**
1783
+ * One short line saying what the switch had to change, or null when the
1784
+ * carried effort was accepted verbatim.
1785
+ */
1786
+ switchNotice(target, level, mapped) {
1787
+ if (!target || !level) return null;
1788
+ if (mapped.id === null) return this.t("noEffortModel", { model: target.name || target.id || "" });
1789
+ if (mapped.id !== level.id) return this.t("clamped", { from: level.name || level.id, to: effortName(target, mapped.id) });
1790
+ return null;
1791
+ }
1792
+
1793
+ /** Show a transient footer line; it expires on its own and never sticks. */
1794
+ note(text) {
1795
+ if (!text) return null;
1796
+ clearTimeout(this.noticeTimer);
1797
+ const shown = text;
1798
+ this.noticeTimer = setTimeout(() => {
1799
+ if (this.store.getSnapshot().notice === shown) this.store.set({ notice: null });
1800
+ }, 9000);
1801
+ return shown;
1802
+ }
1803
+
1804
+ /* ---------------- catalogue ---------------- */
1805
+
1806
+ async refresh() {
1807
+ if (this.loading) return;
1808
+ this.loading = true;
1809
+ /* Reading a projection before subscribing to it can return no value at
1810
+ all, so the follow-up starts the subscription first. */
1811
+ this.bindSelection();
1812
+ let remote;
1813
+ try {
1814
+ remote = this.ctx.remote.session;
1815
+ } catch (err) {
1816
+ this.loading = false;
1817
+ this.store.set({ supported: false, ready: true, levels: [], modelName: "", error: null, debug: "inject:" + messageOf(err) });
1818
+ return;
1819
+ }
1820
+ if (!remote || typeof remote.modelCatalog !== "function") {
1821
+ this.loading = false;
1822
+ this.store.set({
1823
+ supported: false,
1824
+ ready: true,
1825
+ levels: [],
1826
+ modelName: "",
1827
+ error: null,
1828
+ debug: "shape:" + (remote ? Object.keys(remote).slice(0, 8).join("+") : "undef")
1829
+ });
1830
+ return;
1831
+ }
1832
+ try {
1833
+ const res = await withTimeout(remote.modelCatalog(), SELECT_TIMEOUT_MS);
1834
+ if (!res || !res.ok) {
1835
+ this.store.set({ ready: true, error: messageOf(res && res.error) || "modelCatalog failed", debug: "res:" + (res ? String(res.ok) : "null") });
1836
+ return;
1837
+ }
1838
+ const catalog = res.value || {};
1839
+ this.catalog = catalog;
1840
+ const fallback = catalog.default || {};
1841
+ /* The durable projection is authoritative: it carries the user's
1842
+ pending intent immediately after a `model/selection` event and the
1843
+ last actually-used configuration after a `request/header`. */
1844
+ const projected = this.projectedSelection();
1845
+ const sel = Object.assign({}, fallback, projected || {});
1846
+ this.selection = Object.assign({}, sel);
1847
+ const model = findModel(catalog, sel) || findModel(catalog, fallback);
1848
+ const reasoning = model && model.reasoning ? model.reasoning : void 0;
1849
+ const efforts = reasoning && Array.isArray(reasoning.efforts) ? reasoning.efforts : [];
1850
+ const levels = efforts
1851
+ .filter((ef) => ef && ef.id !== void 0 && ef.id !== null)
1852
+ .map((ef) => ({
1853
+ id: String(ef.id),
1854
+ name: String(ef.name || ef.id),
1855
+ description: ef.description ? String(ef.description) : ""
1856
+ }));
1857
+ const fallbackId = reasoning && reasoning.defaultEffort ? String(reasoning.defaultEffort) : "";
1858
+ let index = levels.findIndex((l) => l.id === String(sel.reasoningEffort || ""));
1859
+ if (index < 0 && fallbackId) index = levels.findIndex((l) => l.id === fallbackId);
1860
+ if (index < 0) index = Math.max(0, levels.length - 1);
1861
+ this.levels = levels;
1862
+ this.defaultIndex = index;
1863
+ const groups = Array.isArray(catalog.groups) ? catalog.groups : [];
1864
+ const models = groups.map((group) => ({
1865
+ id: String(group && group.id != null ? group.id : ""),
1866
+ name: String((group && (group.name || group.id)) || ""),
1867
+ models: (Array.isArray(group && group.models) ? group.models : [])
1868
+ .filter((m) => m && m.id != null)
1869
+ .map((m) => ({
1870
+ id: String(m.id),
1871
+ name: String(m.name || m.id),
1872
+ description: m.description ? String(m.description) : "",
1873
+ efforts: m.reasoning && Array.isArray(m.reasoning.efforts) ? m.reasoning.efforts.length : 0
1874
+ }))
1875
+ }));
1876
+ this.store.set({
1877
+ levels,
1878
+ models,
1879
+ modelId: String(sel.model || ""),
1880
+ provider: String(sel.provider || ""),
1881
+ index,
1882
+ t: levelT(index, levels.length),
1883
+ supported: levels.length > 0,
1884
+ ready: true,
1885
+ error: null,
1886
+ debug: "ok:" + levels.length,
1887
+ modelName: model ? String(model.name || model.id || "") : String(sel.model || "")
1888
+ });
1889
+ } catch (err) {
1890
+ this.store.set({ ready: true, error: messageOf(err), debug: "throw:" + messageOf(err) });
1891
+ } finally {
1892
+ this.loading = false;
1893
+ }
1894
+ }
1895
+
1896
+ /* ---------------- commit ---------------- */
1897
+
1898
+ commit(index) {
1899
+ this.setIndex(index);
1900
+ this.pendingIndex = index;
1901
+ clearTimeout(this.commitTimer);
1902
+ this.commitTimer = setTimeout(() => {
1903
+ this.flush();
1904
+ }, COMMIT_DEBOUNCE_MS);
1905
+ }
1906
+
1907
+ async flush() {
1908
+ const index = this.pendingIndex;
1909
+ this.pendingIndex = -1;
1910
+ if (index < 0) return;
1911
+ const level = this.levels[index];
1912
+ if (!level) return;
1913
+ const remote = readService(() => this.ctx.remote.session);
1914
+ if (!remote || typeof remote.selectModel !== "function") {
1915
+ this.store.set({ error: "selectModel is unavailable in this build" });
1916
+ return;
1917
+ }
1918
+ const sessionId = this.resolveSessionId();
1919
+ if (!sessionId) {
1920
+ this.store.set({ error: this.t("noSession") });
1921
+ return;
1922
+ }
1923
+ const base = this.selection || {};
1924
+ const currentModel = this.catalog ? findModel(this.catalog, base) : void 0;
1925
+ /* A drag is normally inside the current model's own rungs; resolving
1926
+ anyway keeps a stale level (switch still in flight) from being sent
1927
+ as an explicit unsupported effort. */
1928
+ const mapped = currentModel ? resolveEffort(currentModel, level.id) : { id: level.id, from: level.id };
1929
+ /* Send the explicit triple the shipped selector sends, so the Host never
1930
+ has to infer provider/model from a partial request. */
1931
+ const request = {
1932
+ sessionId,
1933
+ provider: String(base.provider || ""),
1934
+ model: String(base.model || ""),
1935
+ ...(mapped.id ? { reasoningEffort: mapped.id } : {})
1936
+ };
1937
+ const token = ++this.token;
1938
+ this.store.set({ busy: true, error: null, notice: null });
1939
+ try {
1940
+ const res = await withTimeout(remote.selectModel(request), SELECT_TIMEOUT_MS);
1941
+ if (token !== this.token) return;
1942
+ if (res && res.ok) {
1943
+ const selected = res.value && res.value.selected;
1944
+ const applied = Object.assign({}, base, selected || {});
1945
+ if (mapped.id) applied.reasoningEffort = mapped.id;
1946
+ else delete applied.reasoningEffort;
1947
+ if (mapped.id) this.lastEffort = mapped.id;
1948
+ this.selection = Object.assign({}, applied);
1949
+ /* The Host appends a `model/selection` event, so the projection now
1950
+ reports this choice; re-read it to confirm and pick up names. */
1951
+ this.store.set({
1952
+ busy: false,
1953
+ error: null,
1954
+ modelId: String(applied.model || ""),
1955
+ provider: String(applied.provider || "")
1956
+ });
1957
+ this.refresh();
1958
+ } else {
1959
+ this.store.set({ busy: false, error: messageOf(res && res.error) || "refused" });
1960
+ }
1961
+ } catch (err) {
1962
+ if (token !== this.token) return;
1963
+ this.store.set({ busy: false, error: messageOf(err) });
1964
+ }
1965
+ }
1966
+ }
1967
+
1968
+ /* ------------------------------------------------------------------ *
1969
+ * components
1970
+ * ------------------------------------------------------------------ */
1971
+
1972
+ function chevronGlyph() {
1973
+ return h(
1974
+ "svg",
1975
+ { width: 11, height: 11, viewBox: "0 0 24 24", "aria-hidden": "true", style: { display: "block" } },
1976
+ h("path", {
1977
+ d: "M6 9l6 6 6-6",
1978
+ fill: "none",
1979
+ stroke: "currentColor",
1980
+ strokeWidth: 2.2,
1981
+ strokeLinecap: "round",
1982
+ strokeLinejoin: "round"
1983
+ })
1984
+ );
1985
+ }
1986
+
1987
+ /**
1988
+ * The whole control lives in the composer's model cell: a trigger that
1989
+ * reads "model · effort" plus a popup that carries the model list and the
1990
+ * glowing effort rail.
1991
+ */
1992
+ function ModelEffortControl(props) {
1993
+ const snap = react.useSyncExternalStore(props.store.subscribe, props.store.getSnapshot);
1994
+ const sid = props.sessionId;
1995
+ react.useEffect(() => {
1996
+ if (sid && typeof props.setSession === "function") props.setSession(sid);
1997
+ }, [sid]);
1998
+
1999
+ const triggerRef = react.useRef(null);
2000
+ const menuRef = react.useRef(null);
2001
+ const trackRef = react.useRef(null);
2002
+ const bgRef = react.useRef(null);
2003
+ const railRef = react.useRef(null);
2004
+ const fxRef = react.useRef(null);
2005
+ const flashRef = react.useRef(null);
2006
+ const bigRef = react.useRef(null);
2007
+ const engineRef = react.useRef(null);
2008
+ const levelsRef = react.useRef(snap.levels);
2009
+ levelsRef.current = snap.levels;
2010
+
2011
+ const posState = react.useState(null);
2012
+ const pos = posState[0];
2013
+ const setPos = posState[1];
2014
+
2015
+ if (engineRef.current === null) engineRef.current = createEngine();
2016
+
2017
+ /* Anchor the popup above the trigger, clamped into the viewport. */
2018
+ react.useEffect(() => {
2019
+ if (!snap.open) {
2020
+ setPos(null);
2021
+ return;
2022
+ }
2023
+ const place = () => {
2024
+ const el = triggerRef.current;
2025
+ if (!el || typeof el.getBoundingClientRect !== "function") return;
2026
+ const rect = el.getBoundingClientRect();
2027
+ /* A detached or hidden trigger reports an all-zero box; keep the last good spot. */
2028
+ if (rect.width < 1 && rect.height < 1) return;
2029
+ const vw = window.innerWidth || 1024;
2030
+ const vh = window.innerHeight || 768;
2031
+ const width = Math.min(400, Math.max(240, vw - 24));
2032
+ /* Right-align the popup to the trigger, the way a composer menu reads. */
2033
+ const left = Math.max(8, Math.min(rect.right - width, vw - width - 8));
2034
+ /*
2035
+ * Open upward by default (the trigger sits in the composer, near the
2036
+ * bottom). On a short viewport — a landscape phone, a small window —
2037
+ * there may be almost no room above, so fall back to opening downward
2038
+ * and cap the height to the space that is actually available. Without
2039
+ * this the popup ran off the top of the screen.
2040
+ */
2041
+ const gap = 8;
2042
+ const spaceAbove = rect.top - gap - 8;
2043
+ const spaceBelow = vh - rect.bottom - gap - 8;
2044
+ const up = spaceAbove >= 220 || spaceAbove >= spaceBelow;
2045
+ const room = Math.max(140, up ? spaceAbove : spaceBelow);
2046
+ setPos(
2047
+ up
2048
+ ? { left, width, bottom: vh - rect.top + gap, maxHeight: room, top: null }
2049
+ : { left, width, top: rect.bottom + gap, maxHeight: room, bottom: null }
2050
+ );
2051
+ };
2052
+ place();
2053
+ window.addEventListener("resize", place);
2054
+ window.addEventListener("scroll", place, true);
2055
+ return () => {
2056
+ window.removeEventListener("resize", place);
2057
+ window.removeEventListener("scroll", place, true);
2058
+ };
2059
+ }, [snap.open]);
2060
+
2061
+ const ready = snap.open && pos !== null;
2062
+ const warpFrom = warpIndexOf(snap.levels);
2063
+
2064
+ react.useEffect(() => {
2065
+ if (!ready) return;
2066
+ const engine = engineRef.current;
2067
+ if (!engine) return;
2068
+ const reduce =
2069
+ typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
2070
+ engine.mount({
2071
+ els: {
2072
+ stage: menuRef.current,
2073
+ track: trackRef.current,
2074
+ bg: bgRef.current,
2075
+ rail: railRef.current,
2076
+ fx: fxRef.current,
2077
+ flash: flashRef.current,
2078
+ big: bigRef.current
2079
+ },
2080
+ count: levelsRef.current.length,
2081
+ warpFrom: warpFrom,
2082
+ texts: { boom: props.t("boom"), after: props.t("afterBoom") },
2083
+ reduce,
2084
+ onLevel: (i) => props.setIndex(i),
2085
+ onRelease: (i) => props.commit(i)
2086
+ });
2087
+ engine.setIndex(snap.index, true);
2088
+ return () => engine.unmount();
2089
+ }, [ready]);
2090
+
2091
+ react.useEffect(() => {
2092
+ const engine = engineRef.current;
2093
+ if (!engine || !ready || !engine.isMounted()) return;
2094
+ engine.setWarpFrom(warpFrom);
2095
+ engine.setCount(snap.levels.length);
2096
+ if (!engine.isDragging()) engine.setIndex(snap.index, true);
2097
+ }, [snap.levels.length, warpFrom]);
2098
+
2099
+ /* Dismiss on an outside press or Escape. */
2100
+ react.useEffect(() => {
2101
+ if (!snap.open) return;
2102
+ const onDown = (ev) => {
2103
+ const target = ev.target;
2104
+ const menu = menuRef.current;
2105
+ const trigger = triggerRef.current;
2106
+ if (menu && typeof menu.contains === "function" && menu.contains(target)) return;
2107
+ if (trigger && typeof trigger.contains === "function" && trigger.contains(target)) return;
2108
+ props.close();
2109
+ };
2110
+ const onEsc = (ev) => {
2111
+ if (ev.key === "Escape") props.close();
2112
+ };
2113
+ document.addEventListener("pointerdown", onDown, true);
2114
+ document.addEventListener("keydown", onEsc, true);
2115
+ return () => {
2116
+ document.removeEventListener("pointerdown", onDown, true);
2117
+ document.removeEventListener("keydown", onEsc, true);
2118
+ };
2119
+ }, [snap.open]);
2120
+
2121
+ const hue = Math.round(hueAtT(snap.t));
2122
+ const current = snap.levels[snap.index];
2123
+ const n = snap.levels.length;
2124
+ const modelText = snap.modelId || snap.modelName || "";
2125
+ const effortText = current ? current.name : "";
2126
+ const locked = props.locked === true;
2127
+ const summary = props.t("triggerHint") + (modelText ? " — " + modelText : "") + (effortText ? " · " + effortText : "") + (snap.error ? " · " + snap.error : "");
2128
+
2129
+ const trigger = h(
2130
+ "button",
2131
+ {
2132
+ ref: triggerRef,
2133
+ key: "trigger",
2134
+ type: "button",
2135
+ className: "es-trigger",
2136
+ title: summary,
2137
+ "aria-label": summary,
2138
+ "aria-haspopup": "dialog",
2139
+ "aria-expanded": snap.open ? "true" : "false",
2140
+ "data-open": snap.open ? "1" : "0",
2141
+ "data-locked": locked ? "1" : "0",
2142
+ disabled: locked,
2143
+ style: { "--es-hue": String(hue), "--es-e": snap.t.toFixed(3) },
2144
+ onClick: () => props.toggle()
2145
+ },
2146
+ h("span", { className: "es-trigger-bolt", key: "b" }, boltGlyph(14, hue)),
2147
+ h("span", { className: "es-trigger-text", key: "m" }, safeLabel(modelText) || props.t("modelLabel")),
2148
+ effortText ? h("span", { className: "es-trigger-sep", key: "s" }, "·") : null,
2149
+ effortText ? h("span", { className: "es-trigger-effort", key: "e" }, safeLabel(effortText)) : null,
2150
+ h("span", { className: "es-trigger-chev", key: "c" }, chevronGlyph())
2151
+ );
2152
+
2153
+ if (!ready) return trigger;
2154
+
2155
+ const footText = snap.error
2156
+ ? props.t("failed", { message: snap.error })
2157
+ : snap.busy
2158
+ ? props.t("switching")
2159
+ : snap.notice
2160
+ ? snap.notice
2161
+ : !snap.supported && snap.ready
2162
+ ? props.t("noReasoning")
2163
+ : current && current.description
2164
+ ? current.description
2165
+ : props.t("hint");
2166
+ const footNote = !snap.error && !snap.busy && !!snap.notice;
2167
+
2168
+ const modelNodes = [];
2169
+ snap.models.forEach((group) => {
2170
+ if (group.name) modelNodes.push(h("div", { key: "gh-" + group.id, className: "es-models-group" }, safeLabel(group.name)));
2171
+ group.models.forEach((entry) => {
2172
+ const on = entry.id === snap.modelId && group.id === snap.provider;
2173
+ modelNodes.push(
2174
+ h(
2175
+ "button",
2176
+ {
2177
+ key: "mi-" + group.id + "-" + entry.id,
2178
+ type: "button",
2179
+ className: "es-model-item",
2180
+ "data-on": on ? "1" : "0",
2181
+ title: entry.description || entry.name,
2182
+ onClick: () => props.chooseModel(group.id, entry.id)
2183
+ },
2184
+ h("span", { className: "es-model-item-name", key: "n" }, safeLabel(entry.name)),
2185
+ entry.efforts === 0 ? h("span", { className: "es-model-item-tag", key: "tag" }, props.t("noLevelsTag")) : null
2186
+ )
2187
+ );
2188
+ });
2189
+ });
2190
+
2191
+ const menu = h(
2192
+ "div",
2193
+ {
2194
+ ref: menuRef,
2195
+ key: "menu",
2196
+ className: "es-menu",
2197
+ role: "dialog",
2198
+ "aria-label": props.t("panelTitle"),
2199
+ style: {
2200
+ left: pos.left + "px",
2201
+ ...(pos.top === null || pos.top === void 0 ? {} : { top: pos.top + "px" }),
2202
+ ...(pos.bottom === null || pos.bottom === void 0 ? {} : { bottom: pos.bottom + "px" }),
2203
+ maxHeight: pos.maxHeight + "px",
2204
+ width: pos.width + "px",
2205
+ "--es-hue": String(hue),
2206
+ "--es-t": snap.t.toFixed(4),
2207
+ "--es-e": snap.t.toFixed(3)
2208
+ }
2209
+ },
2210
+ h("canvas", { className: "es-menu-bg", key: "bg", ref: bgRef }),
2211
+ h("div", { className: "es-menu-vig", key: "vig" }),
2212
+ h(
2213
+ "div",
2214
+ { className: "es-menu-body", key: "body" },
2215
+ h(
2216
+ "button",
2217
+ {
2218
+ className: "es-menu-row",
2219
+ key: "row",
2220
+ type: "button",
2221
+ "data-open": snap.listOpen ? "1" : "0",
2222
+ "aria-expanded": snap.listOpen ? "true" : "false",
2223
+ onClick: () => props.toggleModels()
2224
+ },
2225
+ h("span", { className: "es-menu-row-label", key: "l" }, props.t("modelLabel")),
2226
+ h("span", { className: "es-menu-row-value", key: "v", title: modelText }, safeLabel(modelText) || props.t("noModels")),
2227
+ h("span", { className: "es-menu-chev", key: "c" }, chevronGlyph())
2228
+ ),
2229
+ snap.listOpen
2230
+ ? h(
2231
+ "div",
2232
+ { className: "es-models", key: "models" },
2233
+ modelNodes.length
2234
+ ? modelNodes
2235
+ : h("div", { className: "es-models-group", key: "none" }, props.t("noModels"))
2236
+ )
2237
+ : null,
2238
+ h("div", { className: "es-menu-sep", key: "sep" }),
2239
+ h(
2240
+ "div",
2241
+ { className: "es-effort-head", key: "eh" },
2242
+ h("span", { className: "es-head-glyph", key: "g" }, boltGlyph(13, hue)),
2243
+ h("span", { key: "t" }, props.t("effortLabel")),
2244
+ current ? h("span", { className: "es-tag", key: "tag" }, current.name) : null
2245
+ ),
2246
+ h(
2247
+ "div",
2248
+ { className: "es-track", key: "track", ref: trackRef, onPointerDown: (ev) => engineRef.current.beginDrag(ev) },
2249
+ h("canvas", { className: "es-rail", key: "rail", ref: railRef }),
2250
+ h(
2251
+ "div",
2252
+ { className: "es-ticks", key: "ticks" },
2253
+ snap.levels.map((lv, i) =>
2254
+ h("span", {
2255
+ key: "tick-" + lv.id,
2256
+ className: "es-tick",
2257
+ "data-on": i <= snap.index ? "1" : "0",
2258
+ style: { "--i": n > 1 ? (i / (n - 1)).toFixed(4) : "0" }
2259
+ })
2260
+ )
2261
+ ),
2262
+ h("div", { className: "es-thumb", key: "thumb" })
2263
+ ),
2264
+ h(
2265
+ "div",
2266
+ { className: "es-scale", key: "scale" },
2267
+ snap.levels.map((lv, i) =>
2268
+ h(
2269
+ "button",
2270
+ {
2271
+ key: "sc-" + lv.id,
2272
+ type: "button",
2273
+ className: "es-scale-item",
2274
+ "data-on": i === snap.index ? "1" : "0",
2275
+ style: { left: "calc(31px + (100% - 62px) * " + (n > 1 ? (i / (n - 1)).toFixed(4) : "0") + ")" },
2276
+ title: lv.description || lv.name,
2277
+ onClick: () => props.commit(i)
2278
+ },
2279
+ lv.name
2280
+ )
2281
+ )
2282
+ ),
2283
+ h("div", { className: "es-foot", key: "foot", "data-err": snap.error ? "1" : "0", "data-note": footNote ? "1" : "0" }, footText)
2284
+ ),
2285
+ h("canvas", { className: "es-fx", key: "fx", ref: fxRef }),
2286
+ h("div", { className: "es-flash", key: "flash", ref: flashRef }),
2287
+ h("div", { className: "es-big", key: "big", ref: bigRef })
2288
+ );
2289
+
2290
+ /* The popup is portalled to <body>: the composer row can sit inside a
2291
+ transformed or overflow-clipped ancestor, which would otherwise move
2292
+ or cut a position:fixed child. */
2293
+ const portal =
2294
+ reactDom && typeof reactDom.createPortal === "function" && typeof document !== "undefined"
2295
+ ? reactDom.createPortal(menu, document.body)
2296
+ : menu;
2297
+
2298
+ return h("span", { className: "es-anchor", key: "anchor" }, trigger, portal);
2299
+ }
2300
+
2301
+ /* ------------------------------------------------------------------ *
2302
+ * module surface
2303
+ * ------------------------------------------------------------------ */
2304
+
2305
+ /**
2306
+ * BOTH `remote` and `remote.session` are required: Cordis validates the
2307
+ * nested property access (`ctx.get("remote").session`) against this list, and
2308
+ * a missing entry throws `cannot get property "remote.session" without
2309
+ * inject` — which, from a render path, blanks the panel.
2310
+ */
2311
+ const inject = ["slots", "locale", "sessions", "remote", "remote.session"];
2312
+
2313
+ function apply(ctx) {
2314
+ injectCss();
2315
+
2316
+ ctx.effect(
2317
+ () => {
2318
+ try {
2319
+ return ctx.locale.register(NS, { zh, en });
2320
+ } catch (err) {
2321
+ return () => {};
2322
+ }
2323
+ },
2324
+ "effort-slider: dictionaries"
2325
+ );
2326
+
2327
+ const controller = new EffortController(ctx);
2328
+ controller.bindLocale();
2329
+
2330
+ ctx.effect(() => () => controller.dispose(), "effort-slider: controller");
2331
+
2332
+ /* Take over the shipped model cell so model + reasoning effort are one
2333
+ control. A single slot renders the LOWEST priority first
2334
+ (`entries` is sorted ascending by priority, and the renderer takes
2335
+ `entriesOfSlot(key)[0]`), so the shipped occupant (priority 0) is
2336
+ shadowed only by a negative priority. */
2337
+ ctx.slots.inject("conversation.input.model", () => {
2338
+ try {
2339
+ const unregister = ctx.slots.register(
2340
+ {
2341
+ name: "conversation.input.model",
2342
+ priority: -1,
2343
+ label: () => "Model and reasoning effort",
2344
+ inject: () => controller.face()
2345
+ },
2346
+ ModelEffortControl
2347
+ );
2348
+ return () => {
2349
+ unregister();
2350
+ };
2351
+ } catch (err) {
2352
+ return () => {};
2353
+ }
2354
+ });
2355
+ }
2356
+
2357
+ exports.name = "@lolannn/dsh-effort-slider";
2358
+ exports.inject = inject;
2359
+ exports.apply = apply;
2360
+
2361
+ return module.exports;
2362
+ }
2363
+ });