@huai_xin/dsh-model-picker 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,6 +4,16 @@
4
4
 
5
5
  DSH 自带的模型选择器是按两级菜单设计的。模型一多,找一个模型要翻两层;思考档位藏在二级菜单里,改一次要点三次。这个插件把它换成一次点开的列表:直接搜、直接选。
6
6
 
7
+ ## 预览
8
+
9
+ 深色主题:
10
+
11
+ ![思考档位面板(深色主题)](docs/effort-dark.png)
12
+
13
+ 浅色主题:
14
+
15
+ ![思考档位面板(浅色主题)](docs/effort-light.png)
16
+
7
17
  ## 它做了什么
8
18
 
9
19
  **可搜索的模型列表** — 点开即输入,按名字或 id 过滤,按 provider 分组显示。
@@ -19,6 +29,10 @@ DSH 自带的模型选择器是按两级菜单设计的。模型一多,找一
19
29
  **档位是一条能拖的滑杆** — 轨道上是各档位的刻度,拖动时滑块跟手,松手才提交一次。滑块整个
20
30
  待在轨道里面,不会压出边界;面板是紧凑尺寸,不占半个屏幕。
21
31
 
32
+ **档位越高,喷射越远越密** — 滑块身后会拖出一束白色方块,强度随档位变化:最低档只飘出几颗、
33
+ 刚离开滑块就散掉,最高档是 60 颗、能一路撒到轨道中段。最高档还会多一层拖尾。粒子跟随档位实时
34
+ 变化,拖动时也跟着滑块走。
35
+
22
36
  **跟着模型列表实时变** — 别的插件改了可用模型(开关模型、导入账号、探测到模型不可用……),这个列表会立刻跟着变,不用刷新页面。
23
37
 
24
38
  **新会话显示默认模型** — 而不是空着或显示"没有可用模型"。
@@ -55,6 +69,11 @@ dsh plugin --profile <你的 profile> add /path/to/dsh-model-picker
55
69
 
56
70
  本插件替换的只是那层 UI:把两级菜单换成一次点开的可搜索列表,把思考档位拆成独立控件。
57
71
 
72
+ ## 会不会挡住别的按钮
73
+
74
+ 不会。这个面板会主动请求宿主抬高输入区的层级(`data-trigger-menu`),所以聊天区右下角那个
75
+ 「跳到底部」按钮不会盖在面板上面。
76
+
58
77
  ## 兼容性
59
78
 
60
79
  需要 DSH `0.2.1-rc` 及以上。插件本身不认识任何具体 provider——任何 provider 插件注册的模型都会出现在列表里。
package/client.js CHANGED
@@ -327,6 +327,29 @@ function createCaptionMark(React) {
327
327
  * @returns the slider element tree.
328
328
  */
329
329
 
330
+ /**
331
+ * Which accent a reasoning level uses, as a string the stylesheet matches on.
332
+ *
333
+ * The two highest stops are the only ones that differ: `top` (the last) takes the
334
+ * violet, `high` (the one before) the amber. Every other level — and the single-level
335
+ * case — returns undefined, which is what leaves the default blue in place and makes
336
+ * the two marked stops read as "above normal" rather than as two arbitrary colours in
337
+ * a five-colour ramp.
338
+ *
339
+ * It lives at module scope because it is shared: the slider marks itself with it, and
340
+ * so does the composer trigger, which renders outside this component's subtree.
341
+ *
342
+ * @param index - the level's position, 0-based.
343
+ * @param count - how many levels the model declares.
344
+ * @returns 'top' | 'high' | undefined
345
+ */
346
+ function tierFor(index, count) {
347
+ if (count <= 1) return undefined
348
+ if (index === count - 1) return 'top'
349
+ if (index === count - 2) return 'high'
350
+ return undefined
351
+ }
352
+
330
353
  function createEffortSlider(React) {
331
354
  const h = React.createElement
332
355
  const CaptionMark = createCaptionMark(React)
@@ -343,24 +366,129 @@ function createEffortSlider(React) {
343
366
  * height. `dx` / `dy` / `sz` / `rot` stop the squares from marching in step, and
344
367
  * the per-particle `--i` turns one keyframe into a continuous stream (see the
345
368
  * negative `animation-delay` in the stylesheet).
369
+ *
370
+ * The entries are ordered so that taking the FIRST N of them always gives a spread
371
+ * across the whole cross-section: the level only ever slices this table, it never
372
+ * reorders it. `dy0` therefore alternates up/down rather than counting from -11 to
373
+ * 11, which would leave a low level emitting only from the top edge.
346
374
  */
347
375
  const EMIT = [
348
- { dy0: -11, dx: -58, dy: -3, sz: 4, rot: 0 },
349
- { dy0: -7, dx: -74, dy: -5, sz: 3, rot: 45 },
350
- { dy0: -3, dx: -62, dy: -1, sz: 5, rot: 0 },
351
- { dy0: 1, dx: -88, dy: 2, sz: 3, rot: 90 },
352
- { dy0: 5, dx: -66, dy: 4, sz: 4, rot: 0 },
353
- { dy0: 9, dx: -80, dy: 6, sz: 3, rot: 45 },
354
- { dy0: -9, dx: -92, dy: -6, sz: 3, rot: 90 },
355
- { dy0: -5, dx: -54, dy: -2, sz: 4, rot: 0 },
356
- { dy0: -1, dx: -70, dy: 0, sz: 3, rot: 45 },
357
- { dy0: 3, dx: -84, dy: 3, sz: 4, rot: 0 },
358
- { dy0: 7, dx: -60, dy: 5, sz: 3, rot: 90 },
359
- { dy0: 11, dx: -76, dy: 7, sz: 3, rot: 0 },
360
- { dy0: -8, dx: -68, dy: -4, sz: 4, rot: 45 },
361
- { dy0: 0, dx: -90, dy: 1, sz: 3, rot: 0 },
362
- { dy0: 6, dx: -52, dy: 4, sz: 4, rot: 90 },
363
- { dy0: -2, dx: -78, dy: -1, sz: 3, rot: 0 }
376
+ { dy0: 0, dx: -143, dy: 1, sz: 3, rot: 0 },
377
+ { dy0: -8, dx: -118, dy: -4, sz: 4, rot: 45 },
378
+ { dy0: 8, dx: -124, dy: 5, sz: 3, rot: 90 },
379
+ { dy0: -4, dx: -98, dy: -2, sz: 4, rot: 0 },
380
+ { dy0: 4, dx: -108, dy: 3, sz: 4, rot: 45 },
381
+ { dy0: -11, dx: -130, dy: -6, sz: 3, rot: 90 },
382
+ { dy0: 11, dx: -136, dy: 7, sz: 3, rot: 0 },
383
+ { dy0: -2, dx: -88, dy: -1, sz: 3, rot: 45 },
384
+ { dy0: 2, dx: -95, dy: 2, sz: 4, rot: 0 },
385
+ { dy0: -6, dx: -150, dy: -3, sz: 3, rot: 90 },
386
+ { dy0: 6, dx: -114, dy: 4, sz: 4, rot: 45 },
387
+ { dy0: -9, dx: -104, dy: -5, sz: 3, rot: 0 },
388
+ { dy0: 9, dx: -128, dy: 6, sz: 3, rot: 90 },
389
+ { dy0: -1, dx: -91, dy: 0, sz: 4, rot: 45 },
390
+ { dy0: 1, dx: -101, dy: 1, sz: 3, rot: 0 },
391
+ { dy0: -5, dx: -110, dy: -2, sz: 4, rot: 90 },
392
+ { dy0: 5, dx: -121, dy: 3, sz: 3, rot: 0 },
393
+ { dy0: -3, dx: -85, dy: -1, sz: 3, rot: 45 },
394
+ // A second pass over the same cross-section, offset in time by the per-particle
395
+ // delay: it doubles the density without making the stream look striped, and gives
396
+ // the high levels enough material to read as a real plume.
397
+ { dy0: -10, dx: -108, dy: -5, sz: 3, rot: 0 },
398
+ { dy0: 10, dx: -104, dy: 5, sz: 3, rot: 45 },
399
+ { dy0: -7, dx: -140, dy: -3, sz: 4, rot: 90 },
400
+ { dy0: 7, dx: -134, dy: 4, sz: 4, rot: 0 },
401
+ { dy0: -5, dx: -95, dy: -2, sz: 3, rot: 45 },
402
+ { dy0: 5, dx: -91, dy: 2, sz: 3, rot: 90 },
403
+ { dy0: -12, dx: -128, dy: -6, sz: 3, rot: 0 },
404
+ { dy0: 12, dx: -121, dy: 6, sz: 3, rot: 45 },
405
+ { dy0: -3, dx: -146, dy: -1, sz: 4, rot: 90 },
406
+ { dy0: 3, dx: -143, dy: 1, sz: 4, rot: 0 },
407
+ { dy0: -1, dx: -101, dy: 0, sz: 3, rot: 45 },
408
+ { dy0: 1, dx: -98, dy: 0, sz: 3, rot: 0 },
409
+ // A third pass. The high levels need enough material to read as a dense plume
410
+ // rather than a sparse spray, and because only a slice of this table is rendered
411
+ // the extra entries cost the low levels nothing. The timing stagger in the
412
+ // stylesheet spreads every pass out, so density rises without banding.
413
+ { dy0: -9, dx: -114, dy: -4, sz: 4, rot: 0 },
414
+ { dy0: 9, dx: -110, dy: 4, sz: 3, rot: 45 },
415
+ { dy0: -4, dx: -136, dy: -2, sz: 3, rot: 90 },
416
+ { dy0: 4, dx: -130, dy: 2, sz: 4, rot: 0 },
417
+ { dy0: -11, dx: -124, dy: -5, sz: 3, rot: 45 },
418
+ { dy0: 11, dx: -118, dy: 5, sz: 3, rot: 90 },
419
+ { dy0: -6, dx: -95, dy: -3, sz: 4, rot: 0 },
420
+ { dy0: 6, dx: -91, dy: 3, sz: 3, rot: 45 },
421
+ { dy0: -2, dx: -140, dy: -1, sz: 3, rot: 90 },
422
+ { dy0: 2, dx: -134, dy: 1, sz: 4, rot: 0 },
423
+ { dy0: -8, dx: -104, dy: -4, sz: 3, rot: 45 },
424
+ { dy0: 8, dx: -101, dy: 4, sz: 3, rot: 90 },
425
+ // A fourth pass, different seam. Each pass re-covers the same cross-section at a
426
+ // different spacing so the stream stays dense instead of clumping into bands; the
427
+ // per-particle delay in the stylesheet then interleaves all four.
428
+ { dy0: -10, dx: -121, dy: -5, sz: 3, rot: 0 },
429
+ { dy0: 10, dx: -114, dy: 5, sz: 4, rot: 45 },
430
+ { dy0: -5, dx: -143, dy: -2, sz: 3, rot: 90 },
431
+ { dy0: 5, dx: -136, dy: 2, sz: 3, rot: 0 },
432
+ { dy0: -12, dx: -108, dy: -6, sz: 3, rot: 45 },
433
+ { dy0: 12, dx: -104, dy: 6, sz: 3, rot: 90 },
434
+ { dy0: -7, dx: -128, dy: -3, sz: 4, rot: 0 },
435
+ { dy0: 7, dx: -124, dy: 3, sz: 3, rot: 45 },
436
+ { dy0: -1, dx: -88, dy: 0, sz: 4, rot: 90 },
437
+ { dy0: 1, dx: -91, dy: 0, sz: 3, rot: 0 },
438
+ { dy0: -9, dx: -134, dy: -4, sz: 3, rot: 45 },
439
+ { dy0: 9, dx: -130, dy: 4, sz: 4, rot: 90 },
440
+ { dy0: -4, dx: -98, dy: -2, sz: 3, rot: 0 },
441
+ { dy0: 4, dx: -95, dy: 2, sz: 3, rot: 45 },
442
+ { dy0: -11, dx: -118, dy: -5, sz: 4, rot: 90 },
443
+ { dy0: 11, dx: -110, dy: 5, sz: 3, rot: 0 },
444
+ { dy0: -3, dx: -150, dy: -1, sz: 3, rot: 45 },
445
+ { dy0: 3, dx: -140, dy: 1, sz: 4, rot: 90 }
446
+ ]
447
+
448
+ /**
449
+ * How much of the exhaust a level gets.
450
+ *
451
+ * The amount of work a level asks for is the one thing the control is saying, so
452
+ * the exhaust scales with it and the spread between the ends is deliberately wide:
453
+ * the lowest level emits a handful of squares barely past the knob, the highest
454
+ * emits the whole table and throws them roughly three times as far. A narrow range
455
+ * (the first version went 0.28..1.00) made every level look the same at a glance.
456
+ *
457
+ * @param index - the level's position, 0-based.
458
+ * @param count - how many levels the model declares.
459
+ * @returns `{ share, reach }`, both 0..1.
460
+ */
461
+ const exhaustFor = (index, count) => {
462
+ if (count <= 1) return { share: 0.5, reach: 0.5 }
463
+ const at = index / (count - 1)
464
+ // The curve is EASED rather than linear so the ends separate visually, and the
465
+ // floor is low enough that the lowest level is a scatter of a few squares rather
466
+ // than a plume. The reach tops out at 1.0 (the table's own distances) so the
467
+ // furthest level still lands inside the rail rather than being clipped by it.
468
+ const eased = at * at
469
+ return { share: 0.08 + eased * 0.92, reach: 0.22 + at * 0.78 }
470
+ }
471
+
472
+ /**
473
+ * The streaks left behind the squares.
474
+ *
475
+ * These read as a SHEET of parallel lines, not as a wedge: every streak is the same
476
+ * length and the same opacity, and they are evenly spaced down the knob's full
477
+ * height. An earlier version tapered them (long in the middle, short at the edges)
478
+ * so the silhouette converged on the knob like a flame, which is not what the bar
479
+ * should look like — the exhaust leaves the whole cross-section at once.
480
+ *
481
+ * The trail is reserved for the TOP level only (see `withTrail` below): it marks the
482
+ * ceiling of the scale, and drawing it at every level left nothing to escalate to.
483
+ *
484
+ * `off` is a PIXEL offset from the knob's centre line, not a percentage: the emitter
485
+ * is a zero-height box, so a percentage resolves against zero and stacks every
486
+ * streak on the centre. The spread covers the knob's diameter (28px) edge to edge.
487
+ */
488
+ const TRAIL = [
489
+ { off: -12 }, { off: -9 }, { off: -6 }, { off: -3 },
490
+ { off: 0 },
491
+ { off: 3 }, { off: 6 }, { off: 9 }, { off: 12 }
364
492
  ]
365
493
 
366
494
  return function EffortSlider({ efforts, activeId, disabled, onChange, t, caption }) {
@@ -395,6 +523,25 @@ function createEffortSlider(React) {
395
523
  // A model with just one effort keeps its stop in the middle.
396
524
  const position = (at) => count <= 1 ? '50%' : `${(at / (count - 1)) * 100}%`
397
525
 
526
+ // The level drives the exhaust: more of the table, and thrown further.
527
+ const exhaust = exhaustFor(index, count)
528
+ /**
529
+ * The trail belongs to the top level only.
530
+ *
531
+ * It is the visual ceiling of the scale — the thing the last stop has that no
532
+ * other stop does. Drawing it at every level (the first version) made the top
533
+ * level feel like more of the same instead of the end of the range.
534
+ */
535
+ const withTrail = count > 1 && index === count - 1
536
+ /**
537
+ * Which accent the current level uses.
538
+ *
539
+ * The two highest stops are the only ones that differ. Everything below keeps the
540
+ * normal blue, which is what makes the two marked stops read as "above normal"
541
+ * rather than as two arbitrary colours in a five-colour ramp.
542
+ */
543
+ const tier = tierFor(index, count)
544
+
398
545
  return h('div', {
399
546
  // `dmp-slider-dragging` drops the left transition on the knob, the fill and
400
547
  // the emitter together. Without it the knob eases to its new stop over 150ms
@@ -402,6 +549,9 @@ function createEffortSlider(React) {
402
549
  // from a position the knob had not reached yet.
403
550
  className: `dmp-slider${dragging === null ? '' : ' dmp-slider-dragging'}`,
404
551
  'aria-disabled': disabled === true,
552
+ // The stylesheet picks the accent from this: 'top' and 'high' are the two
553
+ // levels with their own colour, everything else leaves it unset.
554
+ 'data-tier': tier,
405
555
  style: { '--dmp-effort-at': position(index) }
406
556
  },
407
557
  h('div', { className: 'dmp-slider-head' },
@@ -469,9 +619,22 @@ function createEffortSlider(React) {
469
619
  * track, so it shares the knob's coordinate space and its transition —
470
620
  * which is what keeps the spawn point under the knob while it moves.
471
621
  * The rail's clip-path trims whatever drifts past the end cap.
622
+ *
623
+ * How MANY particles exist and how FAR they travel both scale with the
624
+ * level: `--dmp-emit-reach` multiplies every particle's `dx` / `dy` in the
625
+ * stylesheet, and only the first `share` of the table is rendered at all.
626
+ * Keeping the unused entries out of the DOM (rather than hiding them) also
627
+ * means a low level runs a couple of animations instead of eighteen.
472
628
  */
473
- h('span', { className: 'dmp-emit', 'aria-hidden': true },
474
- EMIT.map((particle, at) => h('i', {
629
+ h('span', {
630
+ className: 'dmp-emit',
631
+ 'aria-hidden': true,
632
+ // The top level is what turns the trail on; CSS cross-fades the change.
633
+ 'data-trail': withTrail ? 'on' : 'off',
634
+ style: { '--dmp-emit-reach': exhaust.reach }
635
+ },
636
+ EMIT.slice(0, Math.max(2, Math.round(EMIT.length * exhaust.share)))
637
+ .map((particle, at) => h('i', {
475
638
  key: at,
476
639
  className: 'dmp-emit-p',
477
640
  style: {
@@ -482,6 +645,14 @@ function createEffortSlider(React) {
482
645
  '--sz': `${particle.sz}px`,
483
646
  '--rot': `${particle.rot}deg`
484
647
  }
648
+ })),
649
+ // The trail is always in the DOM; `data-trail` fades it in at the top level
650
+ // only. Mounting it conditionally made it pop in and out as the knob
651
+ // crossed the last stop, which was the one abrupt moment in the control.
652
+ TRAIL.map((streak, at) => h('i', {
653
+ key: `trail-${at}`,
654
+ className: 'dmp-emit-trail',
655
+ style: { '--n': at, '--off': `${streak.off}px` }
485
656
  }))),
486
657
  h('div', { className: 'dmp-slider-dots', 'aria-hidden': true },
487
658
  efforts.map((effort, at) => h('span', {
@@ -616,9 +787,16 @@ const CSS = `
616
787
  margin-top: -7px; }
617
788
  /* The tag names the level; it is not a brightness meter. Taking the ramp colour here
618
789
  made "Low" render dim grey and "High" near white, so the control appeared to change
619
- weight as the level moved. The ramp stays on the track, where it means intensity. */
790
+ weight as the level moved. The ramp stays on the track, where it means intensity.
791
+
792
+ The two tier levels are the exception, and they are the point of the tiers: the name
793
+ itself takes the tier accent, so the button in the composer row carries the same
794
+ colour as the bar inside the panel it opens. */
620
795
  .dmp-trigger-effort-text { flex: 1 1 auto; min-width: 0; text-align: center;
621
796
  color: var(--dsw-alias-label-primary); font-weight: 600; }
797
+ .dmp-trigger-effort[data-tier="top"] .dmp-trigger-effort-text,
798
+ .dmp-trigger-effort[data-tier="high"] .dmp-trigger-effort-text {
799
+ color: var(--dmp-accent); }
622
800
  /* Hover and open share ONE background, and it is the translucent interactive
623
801
  token, not the opaque layer-2 surface.
624
802
  The layer-2 token resolves to the composer's own surface colour in both themes
@@ -722,12 +900,14 @@ const CSS = `
722
900
  height: 22px; box-sizing: border-box; padding: 0 8px; border-radius: 8px;
723
901
  font-size: 12px; line-height: 1; font-weight: 600; white-space: nowrap;
724
902
  letter-spacing: .01em;
725
- /* The pill is a label, not a rank meter: it must read the SAME at every level.
726
- It used to take the level's ramp colour, so Low rendered dim grey and Max near
727
- white — the same component looking like two different things depending on the
728
- setting. The ramp colour stays on the track, where it encodes intensity. */
903
+ /* The pill is a label, not a rank meter, so it reads the same at every level — EXCEPT
904
+ the two that carry a tier accent: those take the accent colour in the panel's top
905
+ right corner too, which is what ties the label to the bar below it. Without a tier
906
+ the colour is the plain primary label. */
729
907
  color: var(--dsw-alias-label-primary);
730
908
  background: var(--dsw-alias-interactive-bg-hover); }
909
+ .dmp-slider[data-tier="top"] .dmp-slider-value,
910
+ .dmp-slider[data-tier="high"] .dmp-slider-value { color: var(--dmp-accent); }
731
911
  /* Reasoning effort: the slider.
732
912
  ---------------------------------------------------------------------------
733
913
  A thick pill-shaped track with a solid accent fill and a disc riding on it. The
@@ -761,6 +941,23 @@ const CSS = `
761
941
  while the app looks pale — the demo used to hard-code a blue of its own. */
762
942
  .dmp-slider { --dmp-accent: var(--dsw-static-deepseek-450,
763
943
  var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary))); }
944
+ /* The two top levels get their own accent.
945
+ Borrowed from Codex's TUI, which reserves colour for its highest tiers rather than
946
+ painting a ramp across every stop: its Ultra is a violet family and its Max is an
947
+ amber one. Here the LAST stop takes Ultra's violet and the second-to-last takes
948
+ Max's amber, so the two highest levels are the ones that look different — a level
949
+ below that keeps the normal blue, which is what makes the distinction mean
950
+ something.
951
+ The values are the dark-background hues from Codex's tier table. Each tier carries
952
+ a three-hue set there for its gradient animation; only the first is used here, since
953
+ this control does not run that animation.
954
+ data-tier is set by the component on the slider AND on the trigger, because the
955
+ same accent has to reach the level button in the composer row, which is outside the
956
+ panel's subtree. */
957
+ .dmp-slider[data-tier="top"],
958
+ .dmp-trigger-effort[data-tier="top"] { --dmp-accent: #ba82ff; }
959
+ .dmp-slider[data-tier="high"],
960
+ .dmp-trigger-effort[data-tier="high"] { --dmp-accent: #f7c473; }
764
961
  /* Radius is a LITERAL 15px on a 28px-tall rail — deliberately ONE more than half the
765
962
  height, so the ends curve a touch harder than a true semicircle and read as round
766
963
  rather than as pillowed caps. Anything below half looks square; this is the
@@ -823,31 +1020,80 @@ const CSS = `
823
1020
  .dmp-emit { position: absolute; top: 50%; left: var(--dmp-effort-at);
824
1021
  width: 0; height: 0; z-index: 3; pointer-events: none;
825
1022
  transition: left .15s ease-out; }
826
- /* One square. The dy0 offset spreads the spawn points down the knob cross-section; the
827
- extra dy/dx send each one its own way so they do not travel as a block. */
1023
+ /* One square. The dy0 offset spreads the spawn points down the knob cross-section;
1024
+ the extra dy/dx send each one its own way so they do not travel as a block.
1025
+
1026
+ The colour is FIXED WHITE, not a theme token. The particles are meant to read as
1027
+ light thrown off the bar, and the theme's primary label flips to near-black on a
1028
+ light surface — which painted black specks over a pale blue fill. White is the one
1029
+ value that works against both surfaces, so it is deliberately not themed.
1030
+ --dmp-emit-reach (set per level) multiplies the travel: a low level throws its
1031
+ squares a short way, a high level throws them much further. */
828
1032
  .dmp-emit-p { position: absolute; left: 0; top: 0;
829
1033
  width: var(--sz, 4px); height: var(--sz, 4px); border-radius: 1px;
830
- background: color-mix(in srgb, var(--dsw-alias-label-primary) 92%, transparent);
831
- box-shadow: 0 0 4px color-mix(in srgb,
832
- var(--dmp-accent) 80%, transparent);
1034
+ background: #fff;
1035
+ box-shadow: 0 0 4px #fff;
833
1036
  opacity: 0; transform: translate(-50%, -50%);
834
- animation: dmp-emit 1.7s cubic-bezier(.25,.1,.6,1) infinite;
835
- animation-delay: calc(var(--i) * -106ms); }
836
- /* The streak left behind the sheet of squares. */
837
- .dmp-emit::before { content: ''; position: absolute; right: 4px; top: 50%;
838
- width: 54px; height: 8px; transform: translateY(-50%); border-radius: 6px;
1037
+ /* Speed and rate.
1038
+ The duration is how fast one square crosses its path; the delay is how far apart
1039
+ consecutive squares are in time. Shortening both raises the speed AND the firing
1040
+ rate: 1.7s / 106ms read as a lazy drift, 1.05s / 58ms reads as a jet. The delay
1041
+ stays well under the duration on purpose — it has to, or the stream develops a
1042
+ visible seam where one cycle hands over to the next. */
1043
+ animation: dmp-emit 1.05s cubic-bezier(.22,.08,.62,1) infinite;
1044
+ animation-delay: calc(var(--i) * -58ms); }
1045
+ /* The streaks left behind the sheet of squares.
1046
+ A SHEET of parallel lines, not a wedge: all nine are the same length, the same
1047
+ weight and the same opacity, evenly spaced across the knob's diameter. An earlier
1048
+ version tapered them and let each drift, which made the silhouette converge on the
1049
+ knob like a flame; the exhaust should leave the whole cross-section at once, so the
1050
+ lines are deliberately uniform. Only the total length follows the level's reach.
1051
+
1052
+ The offset is a PIXEL amount around the centre, not a percentage: the emitter is a
1053
+ zero-height box, and a percentage top resolves against that zero height, which
1054
+ collapsed every streak onto the centre line.
1055
+
1056
+ The trail is ALWAYS in the DOM and fades in and out through opacity, rather than
1057
+ being mounted only for the top level. Mounting it on the fly made it pop into
1058
+ existence the instant the knob reached the last stop and vanish the instant it left
1059
+ — the one jarring moment in an otherwise smooth control. Because opacity carries
1060
+ the on/off state, the pulse animation drives filter instead, so the two never
1061
+ fight over the same property. */
1062
+ .dmp-emit-trail { position: absolute; right: 4px; top: 50%;
1063
+ width: calc(150px * var(--dmp-emit-reach, 1));
1064
+ height: 2px; border-radius: 1px;
1065
+ transform: translateY(calc(-50% + var(--off, 0px)));
839
1066
  background: linear-gradient(270deg,
840
- color-mix(in srgb, var(--dsw-alias-label-primary) 30%, transparent),
841
- color-mix(in srgb, var(--dsw-alias-label-primary) 8%, transparent) 45%,
842
- transparent);
843
- filter: blur(2px); animation: dmp-emit-trail 1.7s ease-in-out infinite; }
1067
+ #fff 0%,
1068
+ #fff 8%,
1069
+ color-mix(in srgb, #fff 62%, transparent) 26%,
1070
+ color-mix(in srgb, #fff 30%, transparent) 50%,
1071
+ color-mix(in srgb, #fff 11%, transparent) 74%,
1072
+ transparent 100%);
1073
+ opacity: 0;
1074
+ transition: opacity .55s ease;
1075
+ /* The same softness the single bar had: enough to melt the lines into one volume of
1076
+ light at the knob, not enough to lose them as separate lines further out. */
1077
+ filter: blur(1.5px);
1078
+ animation: dmp-emit-pulse 1.7s ease-in-out infinite;
1079
+ /* Staggered per streak so they shimmer out of step instead of pulsing as one band. */
1080
+ animation-delay: calc(var(--n, 0) * -140ms); }
1081
+ /* The top level is the only one that shows the trail, but the change is a fade. */
1082
+ .dmp-emit[data-trail="on"] .dmp-emit-trail { opacity: .78; }
844
1083
  @keyframes dmp-emit {
845
1084
  0% { transform: translate(-50%, -50%) translate(0, var(--dy0)) scale(1); opacity: 0; }
846
1085
  14% { opacity: .95; }
847
1086
  70% { opacity: .5; }
848
1087
  100% { transform: translate(-50%, -50%)
849
- translate(var(--dx), calc(var(--dy0) + var(--dy))) scale(.2) rotate(var(--rot)); opacity: 0; } }
850
- @keyframes dmp-emit-trail { 0%, 100% { opacity: .3; } 50% { opacity: .75; } }
1088
+ translate(calc(var(--dx) * var(--dmp-emit-reach, 1)),
1089
+ calc(var(--dy0) + var(--dy) * var(--dmp-emit-reach, 1)))
1090
+ scale(.2) rotate(var(--rot)); opacity: 0; } }
1091
+ /* The pulse drives filter, not opacity: opacity now carries the fade in and out
1092
+ of the trail, and an animation on the same property would override the transition
1093
+ and snap instead of fading. Brightness reads the same way at this scale. */
1094
+ @keyframes dmp-emit-pulse {
1095
+ 0%, 100% { filter: blur(1.5px) brightness(1); }
1096
+ 50% { filter: blur(1.5px) brightness(1.5); } }
851
1097
  /* A drag is direct manipulation: the knob, the fill and the emitter all snap to the
852
1098
  pointer instead of easing, which keeps the particles born exactly at the knob. */
853
1099
  .dmp-slider-dragging .dmp-slider-thumb,
@@ -862,11 +1108,17 @@ const CSS = `
862
1108
  margin-left: calc(var(--dmp-thumb-size) / -2);
863
1109
  margin-top: calc(var(--dmp-thumb-size) / -2);
864
1110
  display: block; overflow: visible; pointer-events: none; z-index: 5;
865
- /* The knob takes the panel's grey in dark and pure white in light, with a black
866
- ring in both. The two values swing with the host stylesheet's own media query,
867
- so no JS theme detection is involved. */
1111
+ /* The knob is the panel's own grey, ringed in the same grey at partial opacity.
1112
+ Three attempts landed here. Pure black read as a hole (a dark ring on a dark disc
1113
+ on a dark panel is no shape at all). A light grey read as a foreign object — it
1114
+ matched neither the fill nor the track. Using the PANEL's own surface colour keeps
1115
+ the knob in the same family as everything around it, and the partial opacity does
1116
+ the separating: the ring is legible over the pale fill and over the dark track
1117
+ without ever becoming a hard contour.
1118
+ The two values swing with the host stylesheet's own media query, so no JS theme
1119
+ detection is involved. */
868
1120
  --dmp-thumb-fill: var(--dsw-alias-bg-module-platform);
869
- --dmp-thumb-ring: #000;
1121
+ --dmp-thumb-ring: color-mix(in srgb, var(--dsw-alias-bg-module-platform) 45%, transparent);
870
1122
  filter: drop-shadow(0 2px 4px color-mix(in srgb, var(--dsw-alias-label-primary) 28%, transparent));
871
1123
  transition: left .15s ease-out, transform .12s ease; }
872
1124
  .dmp-slider-rail:active .dmp-slider-thumb { transform: scale(1.06); }
@@ -932,14 +1184,14 @@ const CSS = `
932
1184
  .dmp-slider-hint { margin: 5px 0 0; padding-top: 8px;
933
1185
  border-top: 1px solid var(--dsw-alias-border-l1);
934
1186
  font-size: 11px; line-height: 15px; color: var(--dsw-alias-label-secondary); }
935
- /* Light surfaces need a softer knob: a black ring reads as a hard edge on a
936
- near-white panel, so the light theme pulls the ring to the host's border colour
937
- and keeps the fill pure white. The two values swing with the host stylesheet's
938
- own media query, so no JS theme detection is involved. */
1187
+ /* Light surfaces follow the same rule with their own panel colour: white fill, ringed
1188
+ in that same white at partial opacity. On a near-white panel the ring still reads
1189
+ because it sits over the blue fill for most of the travel, and where it crosses the
1190
+ empty track the drop shadow supplies the edge instead. */
939
1191
  @media (prefers-color-scheme: light) {
940
1192
  .dmp-slider-thumb {
941
1193
  --dmp-thumb-fill: var(--dsw-alias-bg-base);
942
- --dmp-thumb-ring: var(--dsw-alias-border-l2);
1194
+ --dmp-thumb-ring: color-mix(in srgb, var(--dsw-alias-bg-base) 55%, transparent);
943
1195
  }
944
1196
  }
945
1197
  @media (prefers-reduced-motion: reduce) {
@@ -1505,6 +1757,11 @@ function createModelPicker(React) {
1505
1757
  type: 'button',
1506
1758
  ref: effortAnchor.ref,
1507
1759
  className: 'dmp-trigger dmp-trigger-effort',
1760
+ // The two highest levels name themselves in their accent colour here, so the
1761
+ // tag in the composer row carries the same colour as the bar in the panel it
1762
+ // opens. The slider computes the value for its own subtree; this component has
1763
+ // to compute it again because the tag is NOT a descendant of the slider.
1764
+ 'data-tier': tierFor(efforts.findIndex((effort) => effort.id === activeEffortId), efforts.length),
1508
1765
  // Only a locked composer disables the level tag. Its own commits flip the
1509
1766
  // directory to `selecting`, and disabling here killed the drag after the
1510
1767
  // first stop — the same reason the slider's range input stays enabled.
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huai_xin/dsh-model-picker",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "A searchable, provider-grouped replacement for the DSH composer's model picker.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -35,7 +35,8 @@
35
35
  "locale/*.json",
36
36
  "icon.svg",
37
37
  "README.md",
38
- "LICENSE"
38
+ "LICENSE",
39
+ "docs/*.png"
39
40
  ],
40
41
  "scripts": {
41
42
  "build": "node scripts/build.mjs",