@lolannn/dsh-effort-slider 0.2.6 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +8 -8
  2. package/client.js +367 -141
  3. package/package.json +60 -60
package/README.md CHANGED
@@ -46,6 +46,14 @@ actually using.
46
46
  four-point stars, a breathing star core, and a three-armed galaxy that
47
47
  collapses out of the rail at the top level. Reaching the top fires a shockwave
48
48
  burst, a star-warp starfield and a light-blade flash.
49
+ - The composer itself carries the light: a halo wraps the whole input card, with
50
+ the same hue as the rail. Brightness, ring alpha and glow radius all follow the
51
+ level, so `off` is a faint rim and the top level breathes; opening the panel
52
+ lifts it. The pill only keeps an outline while the halo is on.
53
+ - The halo is a setting, not a hard rule. A `外圈光环` switch sits under the rail;
54
+ turn it off and the glow returns to the pill alone, the way it was before the
55
+ halo existed. The choice is stored in `localStorage` under
56
+ `effort-slider:halo`, so it survives a reload.
49
57
  - Models without reasoning efforts are tagged `无档位` in the model list and
50
58
  degrade gracefully once selected: the rail is dimmed and the status line says so
51
59
  instead of pretending to switch.
@@ -65,11 +73,6 @@ would clip a `position: fixed` child.
65
73
 
66
74
  - Click the trigger, then drag the rail or press `←` `→` `↑` `↓` `Home` `End`.
67
75
  - The rail snaps to the nearest level on release (elastic overshoot).
68
- - Reaching **high** turns the starfield into warp: the stars stream, the rail
69
- becomes a slow galaxy swirl, and it stays lit for as long as the dial is at
70
- high or above — so `xhigh` and `max` keep the look instead of only the top.
71
- Dropping back below high switches it off. The top level still adds the full
72
- celebration (flash, quake, caption).
73
76
  - The level names under the rail are buttons too — same edge math as the thumb,
74
77
  so each label sits exactly under its stop.
75
78
  - Click the 模型 row to expand the model list; picking a model carries the current
@@ -92,9 +95,6 @@ would clip a `position: fixed` child.
92
95
  - Only transforms, `opacity` and canvas are animated; no layout thrash.
93
96
  - The canvas is sized from the card (`getBoundingClientRect` + `ResizeObserver`),
94
97
  not from the viewport.
95
- - The warp gate is the level named `high`; a model that declares no `high`
96
- warps from the last level at or below it on the shared ladder, and a model
97
- with one level never warps.
98
98
  - Opening the popup while already on the top level arms the burst guard instead of
99
99
  firing it, so the celebration only plays when you actually move into the top
100
100
  level.
package/client.js CHANGED
@@ -50,6 +50,8 @@ window.__ModuleLoader__.load({
50
50
  clamped: "该模型没有「{from}」档,已自动切到最接近的「{to}」",
51
51
  noEffortModel: "「{model}」没有思考强度档位,已按模型默认运行",
52
52
  noLevelsTag: "无档位",
53
+ haloLabel: "外圈光环",
54
+ haloTitle: "在输入框外面加一圈随档位变化的光环;关闭后只在档位按钮周围发光",
53
55
  boom: "宇宙毁灭",
54
56
  afterBoom: "星际探索"
55
57
  };
@@ -72,6 +74,8 @@ window.__ModuleLoader__.load({
72
74
  clamped: "This model has no \"{from}\" level — switched to the nearest one, \"{to}\"",
73
75
  noEffortModel: "\"{model}\" exposes no effort levels — running at its default",
74
76
  noLevelsTag: "no levels",
77
+ haloLabel: "Composer halo",
78
+ haloTitle: "Ring the input box with a glow that follows the effort level; off keeps the glow on the pill only",
75
79
  boom: "SINGULARITY",
76
80
  afterBoom: "DEEP SPACE"
77
81
  };
@@ -84,8 +88,12 @@ window.__ModuleLoader__.load({
84
88
  ".es-anchor{display:inline-flex;align-items:center;min-width:0;max-width:100%}",
85
89
  ".es-trigger{--es-hue:214;--es-e:0;position:relative;display:inline-flex;align-items:center;gap:6px;height:28px;max-width:min(340px,44vw);padding:0 9px;margin:0;border-radius:999px;border:1px solid transparent;background:transparent;color:#c3cbdb;font:inherit;font-size:12.5px;font-weight:500;line-height:1;cursor:pointer;white-space:nowrap;transition:background .2s ease,border-color .25s ease,color .2s ease,box-shadow .35s ease}",
86
90
  ".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)}',
91
+ '.es-trigger[data-open="1"]{background:rgba(255,255,255,.09);border-color:hsl(var(--es-hue) 100% 76% / .34);color:#fff}',
88
92
  '.es-trigger[data-locked="1"]{opacity:.5;cursor:default}',
93
+ ".es-halo{position:fixed;z-index:2147482600;pointer-events:none;opacity:0;border-radius:20px;border:1px solid hsl(var(--es-hue) 100% 74% / var(--es-a0,.12));box-shadow:0 0 0 1px hsl(var(--es-hue) 100% 74% / var(--es-a1,.05)),0 0 var(--es-b1,10px) hsl(var(--es-hue) 100% 64% / var(--es-a2,.1)),0 0 var(--es-b2,34px) hsl(var(--es-hue) 100% 58% / var(--es-a3,.05));transition:opacity .5s ease,box-shadow .45s ease,border-color .45s ease;will-change:opacity,box-shadow}",
94
+ '.es-halo[data-on="1"]{opacity:1}',
95
+ '.es-halo[data-hot="1"]{animation:es-halo-breathe 3.6s ease-in-out infinite}',
96
+ "@keyframes es-halo-breathe{0%,100%{opacity:.62}50%{opacity:1}}",
89
97
  ".es-trigger-bolt{display:var(--dsh-composer-model-icon-display,none);width:14px;height:14px;flex:none;align-items:center;justify-content:center;color:hsl(var(--es-hue) 100% 78%)}",
90
98
  ".es-trigger-text{display:var(--dsh-composer-model-text-display,inline);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:180px}",
91
99
  ".es-trigger-effort{display:var(--dsh-composer-model-text-display,inline);font-weight:600;color:hsl(var(--es-hue) 100% 84%);text-shadow:0 0 10px hsl(var(--es-hue) 100% 70% / .45)}",
@@ -133,6 +141,12 @@ window.__ModuleLoader__.load({
133
141
  ".es-foot{display:flex;align-items:center;gap:8px;padding:10px 2px 0;font-size:11.5px;color:#7f8798;min-height:18px;line-height:1.4}",
134
142
  '.es-foot[data-err="1"]{color:#ff9a9a}',
135
143
  '.es-foot[data-note="1"]{color:hsl(var(--es-hue) 100% 84%)}',
144
+ ".es-opt{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 2px 0;font-size:11.5px;color:#8b93a6}",
145
+ ".es-switch{flex:none;width:34px;height:19px;padding:0;border-radius:999px;border:1px solid rgba(255,255,255,.14);background:rgba(255,255,255,.06);position:relative;cursor:pointer;transition:background .2s ease,border-color .2s ease}",
146
+ '.es-switch[data-on="1"]{background:hsl(var(--es-hue) 100% 62% / .38);border-color:hsl(var(--es-hue) 100% 70% / .55)}',
147
+ ".es-switch-knob{position:absolute;top:1.5px;left:1.5px;width:14px;height:14px;border-radius:50%;background:#cfd6e6;transition:transform .18s ease,background .18s ease}",
148
+ '.es-switch[data-on="1"] .es-switch-knob{transform:translateX(15px);background:#fff}',
149
+ '.es-trigger[data-halo="0"][data-open="1"]{box-shadow:0 0 14px hsl(var(--es-hue) 100% 66% / .28)}',
136
150
  ".es-fx{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;mix-blend-mode:screen;display:block}",
137
151
  ".es-flash{position:absolute;inset:0;pointer-events:none;opacity:0;background:radial-gradient(60% 60% at 50% 50%,#fff,rgba(190,215,255,.6) 45%,transparent 72%)}",
138
152
  ".es-flash.go{animation:es-flash 1.1s ease-out both}",
@@ -427,9 +441,6 @@ window.__ModuleLoader__.load({
427
441
  anim: null,
428
442
  dragging: false,
429
443
  warp: 0,
430
- heat: 0,
431
- warpFrom: -1,
432
- warpFired: false,
433
444
  topFired: false,
434
445
  reduce: false,
435
446
  els: null,
@@ -514,7 +525,7 @@ window.__ModuleLoader__.load({
514
525
  s.y = Math.random() * 2 - 1;
515
526
  s.z = 0.98;
516
527
  s.born = S.time;
517
- s.life = S.warp > 0.5 ? 6 + Math.random() * 5.5 : 4.2 + Math.random() * 4.2;
528
+ s.life = S.idx === topIndex() ? 6 + Math.random() * 5.5 : 4.2 + Math.random() * 4.2;
518
529
  }
519
530
 
520
531
  function initStars() {
@@ -558,23 +569,8 @@ window.__ModuleLoader__.load({
558
569
  }
559
570
  }
560
571
 
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
572
  function ensureParticles() {
577
- const spec = railSpec();
573
+ const spec = specAt(S.t);
578
574
  buildParticles(spec.n, spec.kind);
579
575
  }
580
576
 
@@ -763,7 +759,7 @@ window.__ModuleLoader__.load({
763
759
  const hh = S.rh;
764
760
  c.setTransform(S.rdpr, 0, 0, S.rdpr, 0, 0);
765
761
  c.clearRect(0, 0, w, hh);
766
- const spec = railSpec();
762
+ const spec = specAt(S.t);
767
763
  const e = S.e;
768
764
  const hue = hueAtT(S.t);
769
765
  const pad = 11;
@@ -839,26 +835,22 @@ window.__ModuleLoader__.load({
839
835
  }
840
836
 
841
837
  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);
838
+ const ga = clamp01((e - 0.9) / 0.1);
839
+ const cxx = pad + iw * 0.5;
840
+ const core = c.createRadialGradient(cxx, cyy, 0, cxx, cyy, iw * 0.24);
849
841
  core.addColorStop(0, "rgba(255,255,255,.95)");
850
842
  core.addColorStop(0.35, "hsla(196,100%,82%,.75)");
851
843
  core.addColorStop(0.72, "hsla(258,100%,62%,.34)");
852
844
  core.addColorStop(1, "hsla(258,100%,60%,0)");
853
845
  c.fillStyle = core;
854
846
  c.beginPath();
855
- c.arc(cxx, cyy, gr, 0, Math.PI * 2);
847
+ c.arc(cxx, cyy, iw * 0.24, 0, Math.PI * 2);
856
848
  c.fill();
857
849
  for (let a = 0; a < 3; a++) {
858
850
  for (let i = 0; i < 130; i++) {
859
851
  const f = i / 130;
860
852
  const ang = a * ((Math.PI * 2) / 3) + f * 5.4 + S.time * 0.55;
861
- const rad = f * gw * 0.46;
853
+ const rad = f * iw * 0.46;
862
854
  const x = cxx + Math.cos(ang) * rad;
863
855
  const y = cyy + Math.sin(ang) * rad * 0.42;
864
856
  const alpha = (1 - f) * 0.5 * (0.4 + ga * 0.6);
@@ -959,33 +951,6 @@ window.__ModuleLoader__.load({
959
951
  }
960
952
  }
961
953
 
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
954
  function boom() {
990
955
  const els = S.els;
991
956
  if (!els) return;
@@ -1049,27 +1014,13 @@ window.__ModuleLoader__.load({
1049
1014
  }
1050
1015
  }
1051
1016
  }
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) {
1017
+ const isTop = top > 0 && i === top;
1018
+ if (isTop && !S.topFired) {
1071
1019
  S.topFired = true;
1072
1020
  boom();
1021
+ } else if (!isTop) {
1022
+ S.topFired = false;
1023
+ if (S.warp > 0.01) S.warp = 0;
1073
1024
  }
1074
1025
  }
1075
1026
 
@@ -1103,18 +1054,10 @@ window.__ModuleLoader__.load({
1103
1054
  if (S.prevFade > 0) S.prevFade = Math.max(0, S.prevFade - dt / 0.24);
1104
1055
 
1105
1056
  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
1057
  ensureParticles();
1114
1058
 
1115
1059
  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();
1060
+ if (!S.reduce && top > 0 && S.idx === top && S.frameNo % 10 === 0) spawnStreaks();
1118
1061
 
1119
1062
  drawBG(dt);
1120
1063
  drawRail();
@@ -1206,14 +1149,12 @@ window.__ModuleLoader__.load({
1206
1149
  S.texts = opts.texts || S.texts;
1207
1150
  S.reduce = !!opts.reduce;
1208
1151
  S.count = Math.max(1, (opts.count || 1) | 0);
1209
- S.warpFrom = typeof opts.warpFrom === "number" ? opts.warpFrom | 0 : -1;
1210
1152
  S.mounted = true;
1211
1153
  S.time = 0;
1212
1154
  S.frameNo = 0;
1213
1155
  S.last = 0;
1214
1156
  S.e = S.t;
1215
1157
  S.topFired = false;
1216
- S.warpFired = false;
1217
1158
  S.warp = 0;
1218
1159
  S.waves.length = 0;
1219
1160
  S.debris.length = 0;
@@ -1282,20 +1223,6 @@ window.__ModuleLoader__.load({
1282
1223
  S.idx = Math.min(S.idx, topIndexOf(S.count));
1283
1224
  }
1284
1225
 
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
1226
  function setIndex(index, immediate) {
1300
1227
  const top = topIndex();
1301
1228
  const to = levelT(clamp(index, 0, top), S.count);
@@ -1305,17 +1232,11 @@ window.__ModuleLoader__.load({
1305
1232
  S.e = to;
1306
1233
  S.idx = Math.round(to * top);
1307
1234
  /*
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".
1235
+ * Opening the panel must not replay the top-level celebration. Only a
1236
+ * real move into the top level (drag, click, keyboard) fires boom();
1237
+ * mounting while already at the top just arms the guard.
1313
1238
  */
1314
- const gate = S.warpFrom >= 0 ? Math.min(S.warpFrom, top) : top;
1315
- const inside = S.count > 1 && S.idx >= gate;
1316
1239
  S.topFired = S.count > 1 && S.idx === top;
1317
- S.warpFired = inside;
1318
- S.warp = inside ? 1 : 0;
1319
1240
  } else {
1320
1241
  snapTo(to, 320);
1321
1242
  }
@@ -1325,7 +1246,6 @@ window.__ModuleLoader__.load({
1325
1246
  mount,
1326
1247
  unmount,
1327
1248
  setCount,
1328
- setWarpFrom,
1329
1249
  setIndex,
1330
1250
  beginDrag,
1331
1251
  isDragging() {
@@ -1483,25 +1403,231 @@ window.__ModuleLoader__.load({
1483
1403
  }
1484
1404
 
1485
1405
  /**
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.
1406
+ * The composer card that owns the trigger. The shipped DOM uses hashed
1407
+ * CSS-module names (`<hash>_card`), so match that suffix first and fall
1408
+ * back to "the nearest rounded box that owns a text field".
1492
1409
  */
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;
1410
+ /* A preference, not state: the halo can be switched off, and the choice
1411
+ outlives the page. localStorage is guarded because a host context may
1412
+ refuse it. */
1413
+ const HALO_KEY = "effort-slider:halo";
1414
+ function readHaloPref() {
1415
+ try {
1416
+ if (typeof window === "undefined" || !window.localStorage) return true;
1417
+ const raw = window.localStorage.getItem(HALO_KEY);
1418
+ return raw === null ? true : raw !== "0";
1419
+ } catch (err) {
1420
+ return true;
1421
+ }
1422
+ }
1423
+ function writeHaloPref(on) {
1424
+ try {
1425
+ if (typeof window === "undefined" || !window.localStorage) return;
1426
+ window.localStorage.setItem(HALO_KEY, on ? "1" : "0");
1427
+ } catch (err) {
1428
+ /* a preference that cannot persist still applies to this page */
1429
+ }
1430
+ }
1431
+
1432
+ function composerBox(el) {
1433
+ if (!el || typeof el.closest !== "function") return null;
1434
+ const card = el.closest('[class*="_card"]');
1435
+ if (card && card.querySelector && card.querySelector('textarea,[contenteditable="true"],[role="textbox"]')) return card;
1436
+ let node = el.parentElement;
1437
+ while (node && node !== document.body) {
1438
+ if (node.querySelector && node.querySelector('textarea,[contenteditable="true"],[role="textbox"]')) {
1439
+ const cs = window.getComputedStyle(node);
1440
+ if ((parseFloat(cs.borderTopLeftRadius) || 0) >= 10) return node;
1441
+ }
1442
+ node = node.parentElement;
1503
1443
  }
1504
- return best;
1444
+ return null;
1445
+ }
1446
+
1447
+ /** The page shares one ring, so a second mount cannot leave a ghost. */
1448
+ let HALO = null;
1449
+ function haloLayer() {
1450
+ if (HALO === null) HALO = createHalo();
1451
+ return HALO;
1452
+ }
1453
+
1454
+ /**
1455
+ * One fixed ring that hugs the composer. It is created once and reused, so
1456
+ * re-renders never stack copies; the geometry follows the card through a
1457
+ * ResizeObserver plus the window events that can move it.
1458
+ */
1459
+ function createHalo() {
1460
+ let node = null;
1461
+ let target = null;
1462
+ let observer = null;
1463
+ let frame = 0;
1464
+ let mounts = 0;
1465
+ let anchor = null;
1466
+ let pollTimer = 0;
1467
+ let until = 0;
1468
+ let lastGeom = "";
1469
+
1470
+ function paint() {
1471
+ if (!node) return;
1472
+ /*
1473
+ * The host may rebuild the composer node (a session switch, a layout
1474
+ * change) without telling us; look the card up again from the trigger
1475
+ * rather than keeping a ring pinned to a detached node.
1476
+ */
1477
+ if ((!target || !target.isConnected) && anchor && anchor.isConnected) {
1478
+ const again = composerBox(anchor);
1479
+ if (again !== target) {
1480
+ target = again;
1481
+ if (again && typeof window.ResizeObserver === "function") {
1482
+ if (observer && typeof observer.disconnect === "function") observer.disconnect();
1483
+ observer = new window.ResizeObserver(schedule);
1484
+ observer.observe(again);
1485
+ }
1486
+ }
1487
+ }
1488
+ if (!target || !target.isConnected) {
1489
+ hide();
1490
+ return;
1491
+ }
1492
+ const rect = target.getBoundingClientRect();
1493
+ if (!rect.width || !rect.height || rect.bottom < 0 || rect.top > window.innerHeight) {
1494
+ hide();
1495
+ return;
1496
+ }
1497
+ /*
1498
+ * The composer moves without resizing — a Session switch turns the
1499
+ * centred empty state into the docked conversation view, and a pure
1500
+ * ResizeObserver never fires for that. Re-read the geometry instead of
1501
+ * trusting the last placed rect, but only touch the DOM when it moved.
1502
+ */
1503
+ const key = rect.left + "," + rect.top + "," + rect.width + "," + rect.height;
1504
+ if (key === lastGeom && node.dataset.on === "1") return;
1505
+ lastGeom = key;
1506
+ const radius = parseFloat(window.getComputedStyle(target).borderTopLeftRadius) || 16;
1507
+ node.style.left = rect.left - 2 + "px";
1508
+ node.style.top = rect.top - 2 + "px";
1509
+ node.style.width = rect.width + 4 + "px";
1510
+ node.style.height = rect.height + 4 + "px";
1511
+ node.style.borderRadius = radius + 2 + "px";
1512
+ }
1513
+
1514
+ /** Frames follow the card closely for a moment after anything may have moved it. */
1515
+ function watchFor(ms) {
1516
+ until = Math.max(until, Date.now() + ms);
1517
+ if (frame) return;
1518
+ frame = window.requestAnimationFrame(track);
1519
+ }
1520
+
1521
+ function track() {
1522
+ frame = 0;
1523
+ if (!node) return;
1524
+ paint();
1525
+ if (!target || Date.now() >= until) return;
1526
+ frame = window.requestAnimationFrame(track);
1527
+ }
1528
+
1529
+ function schedule() {
1530
+ watchFor(1200);
1531
+ }
1532
+
1533
+ /* A layout change can also land long after the switch, so a slow poll
1534
+ keeps watching for as long as the ring exists. */
1535
+ function poll() {
1536
+ pollTimer = setTimeout(() => {
1537
+ pollTimer = 0;
1538
+ if (!node) return;
1539
+ paint();
1540
+ poll();
1541
+ }, 250);
1542
+ }
1543
+
1544
+ function startPoll() {
1545
+ if (pollTimer || typeof setTimeout !== "function") return;
1546
+ poll();
1547
+ }
1548
+
1549
+ function stopPoll() {
1550
+ if (!pollTimer) return;
1551
+ clearTimeout(pollTimer);
1552
+ pollTimer = 0;
1553
+ }
1554
+
1555
+ /** Leaving no paint behind matters more than leaving no state behind. */
1556
+ function hide() {
1557
+ if (!node) return;
1558
+ node.dataset.on = "0";
1559
+ node.dataset.hot = "0";
1560
+ node.style.opacity = "0.000";
1561
+ node.style.visibility = "hidden";
1562
+ lastGeom = "";
1563
+ }
1564
+
1565
+ function ensure() {
1566
+ if (node) return node;
1567
+ /* An earlier copy of this module may have left its ring in the body. */
1568
+ if (typeof document.querySelectorAll === "function") {
1569
+ const stale = document.querySelectorAll(".es-halo");
1570
+ for (let i = 0; i < stale.length; i += 1) {
1571
+ if (stale[i].parentNode) stale[i].parentNode.removeChild(stale[i]);
1572
+ }
1573
+ }
1574
+ node = document.createElement("div");
1575
+ node.className = "es-halo";
1576
+ node.setAttribute("aria-hidden", "true");
1577
+ document.body.appendChild(node);
1578
+ window.addEventListener("resize", schedule, { passive: true });
1579
+ document.addEventListener("scroll", schedule, { capture: true, passive: true });
1580
+ return node;
1581
+ }
1582
+
1583
+ function place(box, opts, from) {
1584
+ const el = ensure();
1585
+ if (from) anchor = from;
1586
+ if (box !== target) {
1587
+ target = box;
1588
+ if (observer && typeof observer.disconnect === "function") observer.disconnect();
1589
+ observer = null;
1590
+ if (target && typeof window.ResizeObserver === "function") {
1591
+ observer = new window.ResizeObserver(schedule);
1592
+ observer.observe(target);
1593
+ }
1594
+ lastGeom = "";
1595
+ if (target) startPoll();
1596
+ watchFor(3000);
1597
+ }
1598
+ const heat = Math.max(0, Math.min(1, Number(opts.heat) || 0));
1599
+ el.style.setProperty("--es-hue", String(opts.hue | 0));
1600
+ el.style.setProperty("--es-a0", (0.1 + heat * 0.34).toFixed(3));
1601
+ el.style.setProperty("--es-a1", (0.04 + heat * 0.14).toFixed(3));
1602
+ el.style.setProperty("--es-a2", (0.1 + heat * 0.44).toFixed(3));
1603
+ el.style.setProperty("--es-a3", (0.05 + heat * 0.3).toFixed(3));
1604
+ el.style.setProperty("--es-b1", (10 + heat * 26).toFixed(1) + "px");
1605
+ el.style.setProperty("--es-b2", (34 + heat * 78).toFixed(1) + "px");
1606
+ el.style.opacity = String((!target ? 0 : opts.open ? 0.62 + heat * 0.38 : 0.2 + heat * 0.8).toFixed(3));
1607
+ el.style.visibility = target ? "visible" : "hidden";
1608
+ el.dataset.on = target ? "1" : "0";
1609
+ el.dataset.hot = target && opts.hot ? "1" : "0";
1610
+ if (!target) hide();
1611
+ else schedule();
1612
+ }
1613
+
1614
+ function retain() {
1615
+ mounts += 1;
1616
+ }
1617
+
1618
+ function release() {
1619
+ mounts = Math.max(0, mounts - 1);
1620
+ if (mounts > 0) return;
1621
+ stopPoll();
1622
+ lastGeom = "";
1623
+ if (observer && typeof observer.disconnect === "function") observer.disconnect();
1624
+ observer = null;
1625
+ target = null;
1626
+ anchor = null;
1627
+ hide();
1628
+ }
1629
+
1630
+ return { place, retain, release };
1505
1631
  }
1506
1632
 
1507
1633
  class EffortController {
@@ -1513,6 +1639,13 @@ window.__ModuleLoader__.load({
1513
1639
  this.defaultIndex = 0;
1514
1640
  this.sessionId = void 0;
1515
1641
  this.loading = false;
1642
+ /* A read requested while another is in flight is queued, not dropped:
1643
+ the correction that follows a Session switch almost always arrives
1644
+ during the first catalogue read, and dropping it left the pill on
1645
+ the deployment default until something else happened to refresh. */
1646
+ this.refreshQueued = false;
1647
+ this.bindTimer = 0;
1648
+ this.bindTries = 0;
1516
1649
  this.commitTimer = 0;
1517
1650
  this.pendingIndex = -1;
1518
1651
  this.noticeTimer = 0;
@@ -1537,7 +1670,8 @@ window.__ModuleLoader__.load({
1537
1670
  ready: false,
1538
1671
  modelName: "",
1539
1672
  modelId: "",
1540
- provider: ""
1673
+ provider: "",
1674
+ halo: readHaloPref()
1541
1675
  });
1542
1676
  }
1543
1677
 
@@ -1575,6 +1709,7 @@ window.__ModuleLoader__.load({
1575
1709
  close: () => this.close(),
1576
1710
  toggle: () => this.toggle(),
1577
1711
  toggleModels: () => this.toggleModels(),
1712
+ toggleHalo: () => this.toggleHalo(),
1578
1713
  chooseModel: (provider, modelId) => this.chooseModel(provider, modelId),
1579
1714
  reset: () => this.reset(),
1580
1715
  commit: (i) => this.commit(i),
@@ -1583,8 +1718,18 @@ window.__ModuleLoader__.load({
1583
1718
  };
1584
1719
  }
1585
1720
 
1721
+ toggleHalo() {
1722
+ const on = !this.store.getSnapshot().halo;
1723
+ writeHaloPref(on);
1724
+ this.store.set({ halo: on });
1725
+ }
1726
+
1586
1727
  dispose() {
1587
1728
  clearTimeout(this.commitTimer);
1729
+ if (this.bindTimer) {
1730
+ clearTimeout(this.bindTimer);
1731
+ this.bindTimer = 0;
1732
+ }
1588
1733
  this.dropSelectionSubscription();
1589
1734
  }
1590
1735
 
@@ -1670,9 +1815,25 @@ window.__ModuleLoader__.load({
1670
1815
  */
1671
1816
  bindSelection() {
1672
1817
  const face = this.selectionFace();
1673
- if (face === this.selFace) return;
1818
+ /* Both being undefined is not "already bound": a Session that was just
1819
+ created has no binding yet, and comparing undefined to undefined
1820
+ used to skip the retry that fixes it. */
1821
+ if (face) {
1822
+ if (face === this.selFace) return;
1823
+ } else {
1824
+ this.scheduleBind();
1825
+ return;
1826
+ }
1674
1827
  this.dropSelectionSubscription();
1675
- if (!face || typeof face.subscribe !== "function") return;
1828
+ if (typeof face.subscribe !== "function") {
1829
+ this.scheduleBind();
1830
+ return;
1831
+ }
1832
+ this.bindTries = 0;
1833
+ if (this.bindTimer) {
1834
+ clearTimeout(this.bindTimer);
1835
+ this.bindTimer = 0;
1836
+ }
1676
1837
  this.selFace = face;
1677
1838
  this.selUnsub = face.subscribe(() => {
1678
1839
  /* Opening the face and later commits both notify; defer so the
@@ -1683,6 +1844,17 @@ window.__ModuleLoader__.load({
1683
1844
  });
1684
1845
  }
1685
1846
 
1847
+ scheduleBind() {
1848
+ if (this.bindTimer || this.bindTries >= 12) return;
1849
+ const delay = this.bindTries < 6 ? 200 : 1000;
1850
+ this.bindTries += 1;
1851
+ this.bindTimer = setTimeout(() => {
1852
+ this.bindTimer = 0;
1853
+ this.bindSelection();
1854
+ this.refresh();
1855
+ }, delay);
1856
+ }
1857
+
1686
1858
  resolveSessionId() {
1687
1859
  if (this.sessionId) return this.sessionId;
1688
1860
  const sessions = readService(() => this.ctx.sessions);
@@ -1804,8 +1976,22 @@ window.__ModuleLoader__.load({
1804
1976
  /* ---------------- catalogue ---------------- */
1805
1977
 
1806
1978
  async refresh() {
1807
- if (this.loading) return;
1979
+ if (this.loading) {
1980
+ this.refreshQueued = true;
1981
+ return;
1982
+ }
1808
1983
  this.loading = true;
1984
+ try {
1985
+ do {
1986
+ this.refreshQueued = false;
1987
+ await this.refreshOnce();
1988
+ } while (this.refreshQueued);
1989
+ } finally {
1990
+ this.loading = false;
1991
+ }
1992
+ }
1993
+
1994
+ async refreshOnce() {
1809
1995
  /* Reading a projection before subscribing to it can return no value at
1810
1996
  all, so the follow-up starts the subscription first. */
1811
1997
  this.bindSelection();
@@ -1813,12 +1999,10 @@ window.__ModuleLoader__.load({
1813
1999
  try {
1814
2000
  remote = this.ctx.remote.session;
1815
2001
  } catch (err) {
1816
- this.loading = false;
1817
2002
  this.store.set({ supported: false, ready: true, levels: [], modelName: "", error: null, debug: "inject:" + messageOf(err) });
1818
2003
  return;
1819
2004
  }
1820
2005
  if (!remote || typeof remote.modelCatalog !== "function") {
1821
- this.loading = false;
1822
2006
  this.store.set({
1823
2007
  supported: false,
1824
2008
  ready: true,
@@ -1888,8 +2072,6 @@ window.__ModuleLoader__.load({
1888
2072
  });
1889
2073
  } catch (err) {
1890
2074
  this.store.set({ ready: true, error: messageOf(err), debug: "throw:" + messageOf(err) });
1891
- } finally {
1892
- this.loading = false;
1893
2075
  }
1894
2076
  }
1895
2077
 
@@ -2014,6 +2196,33 @@ window.__ModuleLoader__.load({
2014
2196
 
2015
2197
  if (engineRef.current === null) engineRef.current = createEngine();
2016
2198
 
2199
+ /* The glow that used to sit on the pill now wraps the composer card. */
2200
+ react.useEffect(() => {
2201
+ const halo = haloLayer();
2202
+ halo.retain();
2203
+ return () => halo.release();
2204
+ }, []);
2205
+ react.useEffect(() => {
2206
+ const halo = haloLayer();
2207
+ const count = snap.levels.length;
2208
+ if (!snap.halo || !snap.ready || !count) {
2209
+ /* Hide, but keep the mount counted: release() belongs to unmount. */
2210
+ halo.place(null, { hue: 0, heat: 0, open: false, hot: false });
2211
+ return;
2212
+ }
2213
+ const heat = count > 1 ? snap.index / (count - 1) : 0;
2214
+ halo.place(
2215
+ composerBox(triggerRef.current),
2216
+ {
2217
+ hue: Math.round(hueAtT(snap.t)),
2218
+ heat: heat,
2219
+ open: snap.open,
2220
+ hot: heat >= 0.85
2221
+ },
2222
+ triggerRef.current
2223
+ );
2224
+ }, [snap.halo, snap.ready, snap.index, snap.levels.length, snap.open, snap.t]);
2225
+
2017
2226
  /* Anchor the popup above the trigger, clamped into the viewport. */
2018
2227
  react.useEffect(() => {
2019
2228
  if (!snap.open) {
@@ -2059,7 +2268,6 @@ window.__ModuleLoader__.load({
2059
2268
  }, [snap.open]);
2060
2269
 
2061
2270
  const ready = snap.open && pos !== null;
2062
- const warpFrom = warpIndexOf(snap.levels);
2063
2271
 
2064
2272
  react.useEffect(() => {
2065
2273
  if (!ready) return;
@@ -2078,7 +2286,6 @@ window.__ModuleLoader__.load({
2078
2286
  big: bigRef.current
2079
2287
  },
2080
2288
  count: levelsRef.current.length,
2081
- warpFrom: warpFrom,
2082
2289
  texts: { boom: props.t("boom"), after: props.t("afterBoom") },
2083
2290
  reduce,
2084
2291
  onLevel: (i) => props.setIndex(i),
@@ -2091,10 +2298,9 @@ window.__ModuleLoader__.load({
2091
2298
  react.useEffect(() => {
2092
2299
  const engine = engineRef.current;
2093
2300
  if (!engine || !ready || !engine.isMounted()) return;
2094
- engine.setWarpFrom(warpFrom);
2095
2301
  engine.setCount(snap.levels.length);
2096
2302
  if (!engine.isDragging()) engine.setIndex(snap.index, true);
2097
- }, [snap.levels.length, warpFrom]);
2303
+ }, [snap.levels.length]);
2098
2304
 
2099
2305
  /* Dismiss on an outside press or Escape. */
2100
2306
  react.useEffect(() => {
@@ -2139,6 +2345,7 @@ window.__ModuleLoader__.load({
2139
2345
  "aria-expanded": snap.open ? "true" : "false",
2140
2346
  "data-open": snap.open ? "1" : "0",
2141
2347
  "data-locked": locked ? "1" : "0",
2348
+ "data-halo": snap.halo ? "1" : "0",
2142
2349
  disabled: locked,
2143
2350
  style: { "--es-hue": String(hue), "--es-e": snap.t.toFixed(3) },
2144
2351
  onClick: () => props.toggle()
@@ -2280,6 +2487,25 @@ window.__ModuleLoader__.load({
2280
2487
  )
2281
2488
  )
2282
2489
  ),
2490
+ h(
2491
+ "div",
2492
+ { className: "es-opt", key: "opt" },
2493
+ h("span", { key: "l" }, props.t("haloLabel")),
2494
+ h(
2495
+ "button",
2496
+ {
2497
+ type: "button",
2498
+ role: "switch",
2499
+ className: "es-switch",
2500
+ title: props.t("haloTitle"),
2501
+ "aria-label": props.t("haloLabel"),
2502
+ "aria-checked": snap.halo ? "true" : "false",
2503
+ "data-on": snap.halo ? "1" : "0",
2504
+ onClick: () => props.toggleHalo()
2505
+ },
2506
+ h("span", { className: "es-switch-knob", key: "k" })
2507
+ )
2508
+ ),
2283
2509
  h("div", { className: "es-foot", key: "foot", "data-err": snap.error ? "1" : "0", "data-note": footNote ? "1" : "0" }, footText)
2284
2510
  ),
2285
2511
  h("canvas", { className: "es-fx", key: "fx", ref: fxRef }),
package/package.json CHANGED
@@ -1,60 +1,60 @@
1
- {
2
- "name": "@lolannn/dsh-effort-slider",
3
- "version": "0.2.6",
4
- "description": "Replaces the dsh web GUI composer model selector: one control for the model list and its reasoning effort, drawn as a starfield popup with a glowing particle rail, iridescent four-point stars, ribbon waves and a galaxy collapse at the top level.",
5
- "icon": "icon.svg",
6
- "type": "module",
7
- "main": "index.js",
8
- "exports": {
9
- ".": "./index.js",
10
- "./client": "./client.js",
11
- "./package.json": "./package.json"
12
- },
13
- "scripts": {
14
- "check": "node --check client.js && node --check index.js",
15
- "test": "node test/run.cjs",
16
- "prepublishOnly": "npm test"
17
- },
18
- "repository": {
19
- "type": "git",
20
- "url": "git+https://github.com/suyunxing/dsh-effort-slider.git"
21
- },
22
- "homepage": "https://github.com/suyunxing/dsh-effort-slider#readme",
23
- "bugs": {
24
- "url": "https://github.com/suyunxing/dsh-effort-slider/issues"
25
- },
26
- "author": "suyunxing",
27
- "dsh": {
28
- "bundle": {
29
- "patch": "./cordis.patch.yml"
30
- },
31
- "client": {
32
- "inject": [
33
- "@deepseek-ai/dsh-client-connection",
34
- "@deepseek-ai/dsh-client-ui-renderer",
35
- "@deepseek-ai/dsh-api-remotes",
36
- "@deepseek-ai/dsh-api-session-controller"
37
- ],
38
- "platform": "web",
39
- "immediately": true
40
- }
41
- },
42
- "files": [
43
- "index.js",
44
- "client.js",
45
- "icon.svg",
46
- "cordis.patch.yml",
47
- "README.md"
48
- ],
49
- "license": "MIT",
50
- "keywords": [
51
- "dsh",
52
- "dsh-plugin",
53
- "reasoning-effort",
54
- "model-selector",
55
- "composer",
56
- "slider",
57
- "canvas",
58
- "starfield"
59
- ]
60
- }
1
+ {
2
+ "name": "@lolannn/dsh-effort-slider",
3
+ "version": "0.2.7",
4
+ "description": "Replaces the dsh web GUI composer model selector: one control for the model list and its reasoning effort, drawn as a starfield popup with a glowing particle rail, iridescent four-point stars, ribbon waves and a galaxy collapse at the top level.",
5
+ "icon": "icon.svg",
6
+ "type": "module",
7
+ "main": "index.js",
8
+ "exports": {
9
+ ".": "./index.js",
10
+ "./client": "./client.js",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "scripts": {
14
+ "check": "node --check client.js && node --check index.js",
15
+ "test": "node test/run.cjs",
16
+ "prepublishOnly": "npm test"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/suyunxing/dsh-effort-slider.git"
21
+ },
22
+ "homepage": "https://github.com/suyunxing/dsh-effort-slider#readme",
23
+ "bugs": {
24
+ "url": "https://github.com/suyunxing/dsh-effort-slider/issues"
25
+ },
26
+ "author": "suyunxing",
27
+ "dsh": {
28
+ "bundle": {
29
+ "patch": "./cordis.patch.yml"
30
+ },
31
+ "client": {
32
+ "inject": [
33
+ "@deepseek-ai/dsh-client-connection",
34
+ "@deepseek-ai/dsh-client-ui-renderer",
35
+ "@deepseek-ai/dsh-api-remotes",
36
+ "@deepseek-ai/dsh-api-session-controller"
37
+ ],
38
+ "platform": "web",
39
+ "immediately": true
40
+ }
41
+ },
42
+ "files": [
43
+ "index.js",
44
+ "client.js",
45
+ "icon.svg",
46
+ "cordis.patch.yml",
47
+ "README.md"
48
+ ],
49
+ "license": "MIT",
50
+ "keywords": [
51
+ "dsh",
52
+ "dsh-plugin",
53
+ "reasoning-effort",
54
+ "model-selector",
55
+ "composer",
56
+ "slider",
57
+ "canvas",
58
+ "starfield"
59
+ ]
60
+ }