@huai_xin/dsh-model-picker 0.2.0 → 0.3.0
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 +9 -0
- package/client.js +395 -34
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,6 +19,10 @@ DSH 自带的模型选择器是按两级菜单设计的。模型一多,找一
|
|
|
19
19
|
**档位是一条能拖的滑杆** — 轨道上是各档位的刻度,拖动时滑块跟手,松手才提交一次。滑块整个
|
|
20
20
|
待在轨道里面,不会压出边界;面板是紧凑尺寸,不占半个屏幕。
|
|
21
21
|
|
|
22
|
+
**档位越高,喷射越远越密** — 滑块身后会拖出一束白色方块,强度随档位变化:最低档只飘出几颗、
|
|
23
|
+
刚离开滑块就散掉,最高档是 60 颗、能一路撒到轨道中段。最高档还会多一层拖尾。粒子跟随档位实时
|
|
24
|
+
变化,拖动时也跟着滑块走。
|
|
25
|
+
|
|
22
26
|
**跟着模型列表实时变** — 别的插件改了可用模型(开关模型、导入账号、探测到模型不可用……),这个列表会立刻跟着变,不用刷新页面。
|
|
23
27
|
|
|
24
28
|
**新会话显示默认模型** — 而不是空着或显示"没有可用模型"。
|
|
@@ -55,6 +59,11 @@ dsh plugin --profile <你的 profile> add /path/to/dsh-model-picker
|
|
|
55
59
|
|
|
56
60
|
本插件替换的只是那层 UI:把两级菜单换成一次点开的可搜索列表,把思考档位拆成独立控件。
|
|
57
61
|
|
|
62
|
+
## 会不会挡住别的按钮
|
|
63
|
+
|
|
64
|
+
不会。这个面板会主动请求宿主抬高输入区的层级(`data-trigger-menu`),所以聊天区右下角那个
|
|
65
|
+
「跳到底部」按钮不会盖在面板上面。
|
|
66
|
+
|
|
58
67
|
## 兼容性
|
|
59
68
|
|
|
60
69
|
需要 DSH `0.2.1-rc` 及以上。插件本身不认识任何具体 provider——任何 provider 插件注册的模型都会出现在列表里。
|
package/client.js
CHANGED
|
@@ -332,6 +332,142 @@ function createEffortSlider(React) {
|
|
|
332
332
|
const CaptionMark = createCaptionMark(React)
|
|
333
333
|
const { useState, useEffect, useRef } = React
|
|
334
334
|
|
|
335
|
+
/**
|
|
336
|
+
* The exhaust table.
|
|
337
|
+
*
|
|
338
|
+
* Fixed numbers, never `Math.random()`: a render has to be pure, and the stream
|
|
339
|
+
* must look identical every time the panel opens.
|
|
340
|
+
*
|
|
341
|
+
* `dy0` is what makes the particles leave the knob's whole CROSS-SECTION rather
|
|
342
|
+
* than one point in its centre — it spreads the spawn points down the knob's
|
|
343
|
+
* height. `dx` / `dy` / `sz` / `rot` stop the squares from marching in step, and
|
|
344
|
+
* the per-particle `--i` turns one keyframe into a continuous stream (see the
|
|
345
|
+
* negative `animation-delay` in the stylesheet).
|
|
346
|
+
*
|
|
347
|
+
* The entries are ordered so that taking the FIRST N of them always gives a spread
|
|
348
|
+
* across the whole cross-section: the level only ever slices this table, it never
|
|
349
|
+
* reorders it. `dy0` therefore alternates up/down rather than counting from -11 to
|
|
350
|
+
* 11, which would leave a low level emitting only from the top edge.
|
|
351
|
+
*/
|
|
352
|
+
const EMIT = [
|
|
353
|
+
{ dy0: 0, dx: -143, dy: 1, sz: 3, rot: 0 },
|
|
354
|
+
{ dy0: -8, dx: -118, dy: -4, sz: 4, rot: 45 },
|
|
355
|
+
{ dy0: 8, dx: -124, dy: 5, sz: 3, rot: 90 },
|
|
356
|
+
{ dy0: -4, dx: -98, dy: -2, sz: 4, rot: 0 },
|
|
357
|
+
{ dy0: 4, dx: -108, dy: 3, sz: 4, rot: 45 },
|
|
358
|
+
{ dy0: -11, dx: -130, dy: -6, sz: 3, rot: 90 },
|
|
359
|
+
{ dy0: 11, dx: -136, dy: 7, sz: 3, rot: 0 },
|
|
360
|
+
{ dy0: -2, dx: -88, dy: -1, sz: 3, rot: 45 },
|
|
361
|
+
{ dy0: 2, dx: -95, dy: 2, sz: 4, rot: 0 },
|
|
362
|
+
{ dy0: -6, dx: -150, dy: -3, sz: 3, rot: 90 },
|
|
363
|
+
{ dy0: 6, dx: -114, dy: 4, sz: 4, rot: 45 },
|
|
364
|
+
{ dy0: -9, dx: -104, dy: -5, sz: 3, rot: 0 },
|
|
365
|
+
{ dy0: 9, dx: -128, dy: 6, sz: 3, rot: 90 },
|
|
366
|
+
{ dy0: -1, dx: -91, dy: 0, sz: 4, rot: 45 },
|
|
367
|
+
{ dy0: 1, dx: -101, dy: 1, sz: 3, rot: 0 },
|
|
368
|
+
{ dy0: -5, dx: -110, dy: -2, sz: 4, rot: 90 },
|
|
369
|
+
{ dy0: 5, dx: -121, dy: 3, sz: 3, rot: 0 },
|
|
370
|
+
{ dy0: -3, dx: -85, dy: -1, sz: 3, rot: 45 },
|
|
371
|
+
// A second pass over the same cross-section, offset in time by the per-particle
|
|
372
|
+
// delay: it doubles the density without making the stream look striped, and gives
|
|
373
|
+
// the high levels enough material to read as a real plume.
|
|
374
|
+
{ dy0: -10, dx: -108, dy: -5, sz: 3, rot: 0 },
|
|
375
|
+
{ dy0: 10, dx: -104, dy: 5, sz: 3, rot: 45 },
|
|
376
|
+
{ dy0: -7, dx: -140, dy: -3, sz: 4, rot: 90 },
|
|
377
|
+
{ dy0: 7, dx: -134, dy: 4, sz: 4, rot: 0 },
|
|
378
|
+
{ dy0: -5, dx: -95, dy: -2, sz: 3, rot: 45 },
|
|
379
|
+
{ dy0: 5, dx: -91, dy: 2, sz: 3, rot: 90 },
|
|
380
|
+
{ dy0: -12, dx: -128, dy: -6, sz: 3, rot: 0 },
|
|
381
|
+
{ dy0: 12, dx: -121, dy: 6, sz: 3, rot: 45 },
|
|
382
|
+
{ dy0: -3, dx: -146, dy: -1, sz: 4, rot: 90 },
|
|
383
|
+
{ dy0: 3, dx: -143, dy: 1, sz: 4, rot: 0 },
|
|
384
|
+
{ dy0: -1, dx: -101, dy: 0, sz: 3, rot: 45 },
|
|
385
|
+
{ dy0: 1, dx: -98, dy: 0, sz: 3, rot: 0 },
|
|
386
|
+
// A third pass. The high levels need enough material to read as a dense plume
|
|
387
|
+
// rather than a sparse spray, and because only a slice of this table is rendered
|
|
388
|
+
// the extra entries cost the low levels nothing. The timing stagger in the
|
|
389
|
+
// stylesheet spreads every pass out, so density rises without banding.
|
|
390
|
+
{ dy0: -9, dx: -114, dy: -4, sz: 4, rot: 0 },
|
|
391
|
+
{ dy0: 9, dx: -110, dy: 4, sz: 3, rot: 45 },
|
|
392
|
+
{ dy0: -4, dx: -136, dy: -2, sz: 3, rot: 90 },
|
|
393
|
+
{ dy0: 4, dx: -130, dy: 2, sz: 4, rot: 0 },
|
|
394
|
+
{ dy0: -11, dx: -124, dy: -5, sz: 3, rot: 45 },
|
|
395
|
+
{ dy0: 11, dx: -118, dy: 5, sz: 3, rot: 90 },
|
|
396
|
+
{ dy0: -6, dx: -95, dy: -3, sz: 4, rot: 0 },
|
|
397
|
+
{ dy0: 6, dx: -91, dy: 3, sz: 3, rot: 45 },
|
|
398
|
+
{ dy0: -2, dx: -140, dy: -1, sz: 3, rot: 90 },
|
|
399
|
+
{ dy0: 2, dx: -134, dy: 1, sz: 4, rot: 0 },
|
|
400
|
+
{ dy0: -8, dx: -104, dy: -4, sz: 3, rot: 45 },
|
|
401
|
+
{ dy0: 8, dx: -101, dy: 4, sz: 3, rot: 90 },
|
|
402
|
+
// A fourth pass, different seam. Each pass re-covers the same cross-section at a
|
|
403
|
+
// different spacing so the stream stays dense instead of clumping into bands; the
|
|
404
|
+
// per-particle delay in the stylesheet then interleaves all four.
|
|
405
|
+
{ dy0: -10, dx: -121, dy: -5, sz: 3, rot: 0 },
|
|
406
|
+
{ dy0: 10, dx: -114, dy: 5, sz: 4, rot: 45 },
|
|
407
|
+
{ dy0: -5, dx: -143, dy: -2, sz: 3, rot: 90 },
|
|
408
|
+
{ dy0: 5, dx: -136, dy: 2, sz: 3, rot: 0 },
|
|
409
|
+
{ dy0: -12, dx: -108, dy: -6, sz: 3, rot: 45 },
|
|
410
|
+
{ dy0: 12, dx: -104, dy: 6, sz: 3, rot: 90 },
|
|
411
|
+
{ dy0: -7, dx: -128, dy: -3, sz: 4, rot: 0 },
|
|
412
|
+
{ dy0: 7, dx: -124, dy: 3, sz: 3, rot: 45 },
|
|
413
|
+
{ dy0: -1, dx: -88, dy: 0, sz: 4, rot: 90 },
|
|
414
|
+
{ dy0: 1, dx: -91, dy: 0, sz: 3, rot: 0 },
|
|
415
|
+
{ dy0: -9, dx: -134, dy: -4, sz: 3, rot: 45 },
|
|
416
|
+
{ dy0: 9, dx: -130, dy: 4, sz: 4, rot: 90 },
|
|
417
|
+
{ dy0: -4, dx: -98, dy: -2, sz: 3, rot: 0 },
|
|
418
|
+
{ dy0: 4, dx: -95, dy: 2, sz: 3, rot: 45 },
|
|
419
|
+
{ dy0: -11, dx: -118, dy: -5, sz: 4, rot: 90 },
|
|
420
|
+
{ dy0: 11, dx: -110, dy: 5, sz: 3, rot: 0 },
|
|
421
|
+
{ dy0: -3, dx: -150, dy: -1, sz: 3, rot: 45 },
|
|
422
|
+
{ dy0: 3, dx: -140, dy: 1, sz: 4, rot: 90 }
|
|
423
|
+
]
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* How much of the exhaust a level gets.
|
|
427
|
+
*
|
|
428
|
+
* The amount of work a level asks for is the one thing the control is saying, so
|
|
429
|
+
* the exhaust scales with it and the spread between the ends is deliberately wide:
|
|
430
|
+
* the lowest level emits a handful of squares barely past the knob, the highest
|
|
431
|
+
* emits the whole table and throws them roughly three times as far. A narrow range
|
|
432
|
+
* (the first version went 0.28..1.00) made every level look the same at a glance.
|
|
433
|
+
*
|
|
434
|
+
* @param index - the level's position, 0-based.
|
|
435
|
+
* @param count - how many levels the model declares.
|
|
436
|
+
* @returns `{ share, reach }`, both 0..1.
|
|
437
|
+
*/
|
|
438
|
+
const exhaustFor = (index, count) => {
|
|
439
|
+
if (count <= 1) return { share: 0.5, reach: 0.5 }
|
|
440
|
+
const at = index / (count - 1)
|
|
441
|
+
// The curve is EASED rather than linear so the ends separate visually, and the
|
|
442
|
+
// floor is low enough that the lowest level is a scatter of a few squares rather
|
|
443
|
+
// than a plume. The reach tops out at 1.0 (the table's own distances) so the
|
|
444
|
+
// furthest level still lands inside the rail rather than being clipped by it.
|
|
445
|
+
const eased = at * at
|
|
446
|
+
return { share: 0.08 + eased * 0.92, reach: 0.22 + at * 0.78 }
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* The streaks left behind the squares.
|
|
451
|
+
*
|
|
452
|
+
* These read as a SHEET of parallel lines, not as a wedge: every streak is the same
|
|
453
|
+
* length and the same opacity, and they are evenly spaced down the knob's full
|
|
454
|
+
* height. An earlier version tapered them (long in the middle, short at the edges)
|
|
455
|
+
* so the silhouette converged on the knob like a flame, which is not what the bar
|
|
456
|
+
* should look like — the exhaust leaves the whole cross-section at once.
|
|
457
|
+
*
|
|
458
|
+
* The trail is reserved for the TOP level only (see `withTrail` below): it marks the
|
|
459
|
+
* ceiling of the scale, and drawing it at every level left nothing to escalate to.
|
|
460
|
+
*
|
|
461
|
+
* `off` is a PIXEL offset from the knob's centre line, not a percentage: the emitter
|
|
462
|
+
* is a zero-height box, so a percentage resolves against zero and stacks every
|
|
463
|
+
* streak on the centre. The spread covers the knob's diameter (28px) edge to edge.
|
|
464
|
+
*/
|
|
465
|
+
const TRAIL = [
|
|
466
|
+
{ off: -12 }, { off: -9 }, { off: -6 }, { off: -3 },
|
|
467
|
+
{ off: 0 },
|
|
468
|
+
{ off: 3 }, { off: 6 }, { off: 9 }, { off: 12 }
|
|
469
|
+
]
|
|
470
|
+
|
|
335
471
|
return function EffortSlider({ efforts, activeId, disabled, onChange, t, caption }) {
|
|
336
472
|
const count = efforts.length
|
|
337
473
|
const incoming = Math.max(0, efforts.findIndex((effort) => effort.id === activeId))
|
|
@@ -364,8 +500,23 @@ function createEffortSlider(React) {
|
|
|
364
500
|
// A model with just one effort keeps its stop in the middle.
|
|
365
501
|
const position = (at) => count <= 1 ? '50%' : `${(at / (count - 1)) * 100}%`
|
|
366
502
|
|
|
503
|
+
// The level drives the exhaust: more of the table, and thrown further.
|
|
504
|
+
const exhaust = exhaustFor(index, count)
|
|
505
|
+
/**
|
|
506
|
+
* The trail belongs to the top level only.
|
|
507
|
+
*
|
|
508
|
+
* It is the visual ceiling of the scale — the thing the last stop has that no
|
|
509
|
+
* other stop does. Drawing it at every level (the first version) made the top
|
|
510
|
+
* level feel like more of the same instead of the end of the range.
|
|
511
|
+
*/
|
|
512
|
+
const withTrail = count > 1 && index === count - 1
|
|
513
|
+
|
|
367
514
|
return h('div', {
|
|
368
|
-
|
|
515
|
+
// `dmp-slider-dragging` drops the left transition on the knob, the fill and
|
|
516
|
+
// the emitter together. Without it the knob eases to its new stop over 150ms
|
|
517
|
+
// while a transition-free emitter jumps there at once, so the particles left
|
|
518
|
+
// from a position the knob had not reached yet.
|
|
519
|
+
className: `dmp-slider${dragging === null ? '' : ' dmp-slider-dragging'}`,
|
|
369
520
|
'aria-disabled': disabled === true,
|
|
370
521
|
style: { '--dmp-effort-at': position(index) }
|
|
371
522
|
},
|
|
@@ -378,6 +529,40 @@ function createEffortSlider(React) {
|
|
|
378
529
|
h('span', { className: 'dmp-slider-value' }, activeName)),
|
|
379
530
|
|
|
380
531
|
h('div', { className: 'dmp-slider-rail' },
|
|
532
|
+
/**
|
|
533
|
+
* The rail's outline, drawn as vector art.
|
|
534
|
+
*
|
|
535
|
+
* This started as a `box-shadow` inset, which is a RASTER edge: at 1px it
|
|
536
|
+
* lands on the pixel grid but reads flat, and at 1.5px it falls between
|
|
537
|
+
* pixels and smears into a grey band. The knob's ring never had that problem
|
|
538
|
+
* because it is an SVG stroke — a vector edge, crisp at any scale.
|
|
539
|
+
*
|
|
540
|
+
* So the outline uses the same primitive as the knob: a `<rect>` with a
|
|
541
|
+
* stroked edge, sized to the rail and scaled to it by `preserveAspectRatio`
|
|
542
|
+
* being off. `vector-effect: non-scaling-stroke` keeps the stroke exactly
|
|
543
|
+
* 1px however the rect is stretched, so the line stays hairline-crisp instead
|
|
544
|
+
* of being squashed by the horizontal stretch. `rx` is a percentage of the
|
|
545
|
+
* half-height, matching the rail's own rounded ends.
|
|
546
|
+
*/
|
|
547
|
+
h('svg', {
|
|
548
|
+
className: 'dmp-slider-outline',
|
|
549
|
+
'aria-hidden': true,
|
|
550
|
+
preserveAspectRatio: 'none'
|
|
551
|
+
},
|
|
552
|
+
h('rect', {
|
|
553
|
+
// Half the stroke on each axis: an SVG stroke straddles the path, so a 2px
|
|
554
|
+
// line on the exact edge would be half-clipped by the rail's clip-path.
|
|
555
|
+
x: 1,
|
|
556
|
+
y: 1,
|
|
557
|
+
width: 'calc(100% - 2px)',
|
|
558
|
+
height: 'calc(100% - 2px)',
|
|
559
|
+
rx: 14,
|
|
560
|
+
ry: 14,
|
|
561
|
+
fill: 'none',
|
|
562
|
+
stroke: 'var(--dsw-alias-border-l3)',
|
|
563
|
+
strokeWidth: 2,
|
|
564
|
+
vectorEffect: 'non-scaling-stroke'
|
|
565
|
+
})),
|
|
381
566
|
/**
|
|
382
567
|
* The moving parts live in an INSET track.
|
|
383
568
|
*
|
|
@@ -392,6 +577,49 @@ function createEffortSlider(React) {
|
|
|
392
577
|
*/
|
|
393
578
|
h('div', { className: 'dmp-slider-track' },
|
|
394
579
|
h('div', { className: 'dmp-slider-fill', 'aria-hidden': true }),
|
|
580
|
+
/**
|
|
581
|
+
* The exhaust.
|
|
582
|
+
*
|
|
583
|
+
* An empty box pinned at the knob's position (`--dmp-effort-at`), carrying
|
|
584
|
+
* the particle squares. It is a SIBLING of the knob inside the same inset
|
|
585
|
+
* track, so it shares the knob's coordinate space and its transition —
|
|
586
|
+
* which is what keeps the spawn point under the knob while it moves.
|
|
587
|
+
* The rail's clip-path trims whatever drifts past the end cap.
|
|
588
|
+
*
|
|
589
|
+
* How MANY particles exist and how FAR they travel both scale with the
|
|
590
|
+
* level: `--dmp-emit-reach` multiplies every particle's `dx` / `dy` in the
|
|
591
|
+
* stylesheet, and only the first `share` of the table is rendered at all.
|
|
592
|
+
* Keeping the unused entries out of the DOM (rather than hiding them) also
|
|
593
|
+
* means a low level runs a couple of animations instead of eighteen.
|
|
594
|
+
*/
|
|
595
|
+
h('span', {
|
|
596
|
+
className: 'dmp-emit',
|
|
597
|
+
'aria-hidden': true,
|
|
598
|
+
// The top level is what turns the trail on; CSS cross-fades the change.
|
|
599
|
+
'data-trail': withTrail ? 'on' : 'off',
|
|
600
|
+
style: { '--dmp-emit-reach': exhaust.reach }
|
|
601
|
+
},
|
|
602
|
+
EMIT.slice(0, Math.max(2, Math.round(EMIT.length * exhaust.share)))
|
|
603
|
+
.map((particle, at) => h('i', {
|
|
604
|
+
key: at,
|
|
605
|
+
className: 'dmp-emit-p',
|
|
606
|
+
style: {
|
|
607
|
+
'--i': at,
|
|
608
|
+
'--dx': `${particle.dx}px`,
|
|
609
|
+
'--dy0': `${particle.dy0}px`,
|
|
610
|
+
'--dy': `${particle.dy}px`,
|
|
611
|
+
'--sz': `${particle.sz}px`,
|
|
612
|
+
'--rot': `${particle.rot}deg`
|
|
613
|
+
}
|
|
614
|
+
})),
|
|
615
|
+
// The trail is always in the DOM; `data-trail` fades it in at the top level
|
|
616
|
+
// only. Mounting it conditionally made it pop in and out as the knob
|
|
617
|
+
// crossed the last stop, which was the one abrupt moment in the control.
|
|
618
|
+
TRAIL.map((streak, at) => h('i', {
|
|
619
|
+
key: `trail-${at}`,
|
|
620
|
+
className: 'dmp-emit-trail',
|
|
621
|
+
style: { '--n': at, '--off': `${streak.off}px` }
|
|
622
|
+
}))),
|
|
395
623
|
h('div', { className: 'dmp-slider-dots', 'aria-hidden': true },
|
|
396
624
|
efforts.map((effort, at) => h('span', {
|
|
397
625
|
key: effort.id,
|
|
@@ -646,18 +874,31 @@ const CSS = `
|
|
|
646
874
|
The fill and the active label take the host's business-primary token — the same
|
|
647
875
|
accent DSH uses for its own interactive highlights — so the control recolours
|
|
648
876
|
with the theme instead of carrying a blue that only suits one of them. */
|
|
649
|
-
/* The knob is the rail's full height (
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
877
|
+
/* The knob is the rail's full height (28px). Both ends keep the knob clear of the
|
|
878
|
+
rail's cap, but by DIFFERENT amounts — that asymmetry is the requested behaviour,
|
|
879
|
+
not an oversight:
|
|
880
|
+
--dmp-stop-left 28px the first stop sits a full knob diameter in from the
|
|
881
|
+
left cap, so the knob stops well short of that end;
|
|
882
|
+
--dmp-stop-right 16px the last stop sits just past the knob's radius (14), so a
|
|
883
|
+
hairline of track shows beyond the knob at the top level.
|
|
884
|
+
The right gap is deliberately the same weight as the knob's own ring: at 14 the
|
|
885
|
+
knob landed exactly on the cap and the two outlines met, which read as a smudge
|
|
886
|
+
rather than as a stop. The dots, the labels, the fill and the native input's
|
|
887
|
+
travel all derive from these two numbers, which is why they are variables rather
|
|
888
|
+
than four copies of the same value. */
|
|
658
889
|
.dmp-slider { --dmp-rail-height: 28px; --dmp-thumb-size: 28px;
|
|
659
|
-
--dmp-stop-left: 28px; --dmp-stop-right:
|
|
660
|
-
/*
|
|
890
|
+
--dmp-stop-left: 28px; --dmp-stop-right: 16px; }
|
|
891
|
+
/* The accent.
|
|
892
|
+
The alias token is one step too pale on a dark panel: in the dark theme
|
|
893
|
+
--dsw-alias-state-business-primary resolves to the deepseek-400 step, which reads
|
|
894
|
+
as washed out against the panel surface. The ramp's next step down, deepseek-450,
|
|
895
|
+
is the same hue with the weight the bar needs. The alias token stays as the
|
|
896
|
+
fallback so a future ramp change still lands on something.
|
|
897
|
+
The preview pages carry these same values, which is why a preview can look right
|
|
898
|
+
while the app looks pale — the demo used to hard-code a blue of its own. */
|
|
899
|
+
.dmp-slider { --dmp-accent: var(--dsw-static-deepseek-450,
|
|
900
|
+
var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary))); }
|
|
901
|
+
/* Radius is a LITERAL 15px on a 28px-tall rail — deliberately ONE more than half the
|
|
661
902
|
height, so the ends curve a touch harder than a true semicircle and read as round
|
|
662
903
|
rather than as pillowed caps. Anything below half looks square; this is the
|
|
663
904
|
user-tuned value.
|
|
@@ -673,6 +914,14 @@ const CSS = `
|
|
|
673
914
|
margin: 12px 0 4px; border-radius: 15px; flex: none;
|
|
674
915
|
clip-path: inset(0 round 15px);
|
|
675
916
|
background: color-mix(in srgb, var(--dsw-alias-label-primary) 12%, transparent); }
|
|
917
|
+
/* The outline: vector, like the knob's ring.
|
|
918
|
+
An inset box-shadow is a raster edge — 1px reads flat and 1.5px falls between
|
|
919
|
+
pixels and smears. This SVG stretches to the rail with preserveAspectRatio none
|
|
920
|
+
and keeps its stroke at exactly 1px through vector-effect, so the line is as
|
|
921
|
+
crisp as the knob's circle. It sits above the fill so it stays visible across the
|
|
922
|
+
blue, and pointer-events none keeps the range input reachable underneath. */
|
|
923
|
+
.dmp-slider-outline { position: absolute; inset: 0; width: 100%; height: 100%;
|
|
924
|
+
pointer-events: none; z-index: 7; overflow: visible; }
|
|
676
925
|
/* The stop axis. Its ends are the two stop offsets, which is why 0% is the first
|
|
677
926
|
stop and 100% the last — the knob's travel, the dots, the labels and the fill all
|
|
678
927
|
read from this one box. */
|
|
@@ -688,20 +937,108 @@ const CSS = `
|
|
|
688
937
|
.dmp-slider-fill { position: absolute; top: 0; height: var(--dmp-rail-height);
|
|
689
938
|
left: calc(var(--dmp-stop-left) * -1);
|
|
690
939
|
width: calc(var(--dmp-effort-at) + var(--dmp-stop-left));
|
|
691
|
-
background: var(--
|
|
940
|
+
background: var(--dmp-accent);
|
|
692
941
|
z-index: 1; transition: width .15s ease-out; pointer-events: none; }
|
|
693
942
|
.dmp-slider-dots { position: absolute; top: 0; bottom: 0; left: 0; right: 0;
|
|
694
943
|
pointer-events: none; z-index: 2; }
|
|
695
|
-
/* Stops stay SMALL and GREY
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
944
|
+
/* Stops stay SMALL and GREY, and they are only visible AHEAD of the knob: the blue
|
|
945
|
+
fill already says "you are this far", so a dot sitting on it read as dust on the
|
|
946
|
+
colour. Unreached stops keep working as tick marks on the empty part of the track,
|
|
947
|
+
which is where they still carry information. */
|
|
699
948
|
.dmp-slider-dot { position: absolute; top: 50%; width: 5px; height: 5px;
|
|
700
949
|
border-radius: 50%; transform: translate(-50%, -50%);
|
|
701
950
|
background: var(--dsw-alias-label-tertiary, currentColor); opacity: .55;
|
|
702
|
-
transition: opacity .15s ease-out
|
|
703
|
-
|
|
704
|
-
|
|
951
|
+
transition: opacity .15s ease-out; }
|
|
952
|
+
.dmp-slider-dot-lit { opacity: 0; }
|
|
953
|
+
/* The exhaust: a zero-size point pinned to the knob, carrying the particles.
|
|
954
|
+
It sits ABOVE the fill and BELOW the knob, so squares are born behind the disc and
|
|
955
|
+
drift out from under it. Its left offset carries the SAME transition as the knob — that
|
|
956
|
+
is the whole alignment contract: both are driven by --dmp-effort-at, so as long as
|
|
957
|
+
they animate with the same duration and easing the spawn point stays under the
|
|
958
|
+
knob while it travels. (A drag drops the transition on both; see the dragging
|
|
959
|
+
rule below, which is what fixed particles appearing ahead of the knob.) */
|
|
960
|
+
.dmp-emit { position: absolute; top: 50%; left: var(--dmp-effort-at);
|
|
961
|
+
width: 0; height: 0; z-index: 3; pointer-events: none;
|
|
962
|
+
transition: left .15s ease-out; }
|
|
963
|
+
/* One square. The dy0 offset spreads the spawn points down the knob cross-section;
|
|
964
|
+
the extra dy/dx send each one its own way so they do not travel as a block.
|
|
965
|
+
|
|
966
|
+
The colour is FIXED WHITE, not a theme token. The particles are meant to read as
|
|
967
|
+
light thrown off the bar, and the theme's primary label flips to near-black on a
|
|
968
|
+
light surface — which painted black specks over a pale blue fill. White is the one
|
|
969
|
+
value that works against both surfaces, so it is deliberately not themed.
|
|
970
|
+
--dmp-emit-reach (set per level) multiplies the travel: a low level throws its
|
|
971
|
+
squares a short way, a high level throws them much further. */
|
|
972
|
+
.dmp-emit-p { position: absolute; left: 0; top: 0;
|
|
973
|
+
width: var(--sz, 4px); height: var(--sz, 4px); border-radius: 1px;
|
|
974
|
+
background: #fff;
|
|
975
|
+
box-shadow: 0 0 4px #fff;
|
|
976
|
+
opacity: 0; transform: translate(-50%, -50%);
|
|
977
|
+
/* Speed and rate.
|
|
978
|
+
The duration is how fast one square crosses its path; the delay is how far apart
|
|
979
|
+
consecutive squares are in time. Shortening both raises the speed AND the firing
|
|
980
|
+
rate: 1.7s / 106ms read as a lazy drift, 1.05s / 58ms reads as a jet. The delay
|
|
981
|
+
stays well under the duration on purpose — it has to, or the stream develops a
|
|
982
|
+
visible seam where one cycle hands over to the next. */
|
|
983
|
+
animation: dmp-emit 1.05s cubic-bezier(.22,.08,.62,1) infinite;
|
|
984
|
+
animation-delay: calc(var(--i) * -58ms); }
|
|
985
|
+
/* The streaks left behind the sheet of squares.
|
|
986
|
+
A SHEET of parallel lines, not a wedge: all nine are the same length, the same
|
|
987
|
+
weight and the same opacity, evenly spaced across the knob's diameter. An earlier
|
|
988
|
+
version tapered them and let each drift, which made the silhouette converge on the
|
|
989
|
+
knob like a flame; the exhaust should leave the whole cross-section at once, so the
|
|
990
|
+
lines are deliberately uniform. Only the total length follows the level's reach.
|
|
991
|
+
|
|
992
|
+
The offset is a PIXEL amount around the centre, not a percentage: the emitter is a
|
|
993
|
+
zero-height box, and a percentage top resolves against that zero height, which
|
|
994
|
+
collapsed every streak onto the centre line.
|
|
995
|
+
|
|
996
|
+
The trail is ALWAYS in the DOM and fades in and out through opacity, rather than
|
|
997
|
+
being mounted only for the top level. Mounting it on the fly made it pop into
|
|
998
|
+
existence the instant the knob reached the last stop and vanish the instant it left
|
|
999
|
+
— the one jarring moment in an otherwise smooth control. Because opacity carries
|
|
1000
|
+
the on/off state, the pulse animation drives filter instead, so the two never
|
|
1001
|
+
fight over the same property. */
|
|
1002
|
+
.dmp-emit-trail { position: absolute; right: 4px; top: 50%;
|
|
1003
|
+
width: calc(150px * var(--dmp-emit-reach, 1));
|
|
1004
|
+
height: 2px; border-radius: 1px;
|
|
1005
|
+
transform: translateY(calc(-50% + var(--off, 0px)));
|
|
1006
|
+
background: linear-gradient(270deg,
|
|
1007
|
+
#fff 0%,
|
|
1008
|
+
#fff 8%,
|
|
1009
|
+
color-mix(in srgb, #fff 62%, transparent) 26%,
|
|
1010
|
+
color-mix(in srgb, #fff 30%, transparent) 50%,
|
|
1011
|
+
color-mix(in srgb, #fff 11%, transparent) 74%,
|
|
1012
|
+
transparent 100%);
|
|
1013
|
+
opacity: 0;
|
|
1014
|
+
transition: opacity .55s ease;
|
|
1015
|
+
/* The same softness the single bar had: enough to melt the lines into one volume of
|
|
1016
|
+
light at the knob, not enough to lose them as separate lines further out. */
|
|
1017
|
+
filter: blur(1.5px);
|
|
1018
|
+
animation: dmp-emit-pulse 1.7s ease-in-out infinite;
|
|
1019
|
+
/* Staggered per streak so they shimmer out of step instead of pulsing as one band. */
|
|
1020
|
+
animation-delay: calc(var(--n, 0) * -140ms); }
|
|
1021
|
+
/* The top level is the only one that shows the trail, but the change is a fade. */
|
|
1022
|
+
.dmp-emit[data-trail="on"] .dmp-emit-trail { opacity: .78; }
|
|
1023
|
+
@keyframes dmp-emit {
|
|
1024
|
+
0% { transform: translate(-50%, -50%) translate(0, var(--dy0)) scale(1); opacity: 0; }
|
|
1025
|
+
14% { opacity: .95; }
|
|
1026
|
+
70% { opacity: .5; }
|
|
1027
|
+
100% { transform: translate(-50%, -50%)
|
|
1028
|
+
translate(calc(var(--dx) * var(--dmp-emit-reach, 1)),
|
|
1029
|
+
calc(var(--dy0) + var(--dy) * var(--dmp-emit-reach, 1)))
|
|
1030
|
+
scale(.2) rotate(var(--rot)); opacity: 0; } }
|
|
1031
|
+
/* The pulse drives filter, not opacity: opacity now carries the fade in and out
|
|
1032
|
+
of the trail, and an animation on the same property would override the transition
|
|
1033
|
+
and snap instead of fading. Brightness reads the same way at this scale. */
|
|
1034
|
+
@keyframes dmp-emit-pulse {
|
|
1035
|
+
0%, 100% { filter: blur(1.5px) brightness(1); }
|
|
1036
|
+
50% { filter: blur(1.5px) brightness(1.5); } }
|
|
1037
|
+
/* A drag is direct manipulation: the knob, the fill and the emitter all snap to the
|
|
1038
|
+
pointer instead of easing, which keeps the particles born exactly at the knob. */
|
|
1039
|
+
.dmp-slider-dragging .dmp-slider-thumb,
|
|
1040
|
+
.dmp-slider-dragging .dmp-slider-fill,
|
|
1041
|
+
.dmp-slider-dragging .dmp-emit { transition: none; }
|
|
705
1042
|
/* The knob fills the rail's height and is drawn as an SVG, so no inherited
|
|
706
1043
|
border-radius, padding or box-sizing rule can square it off. Its size comes from
|
|
707
1044
|
--dmp-thumb-size; the stop offsets and the input's travel are derived from the
|
|
@@ -711,17 +1048,23 @@ const CSS = `
|
|
|
711
1048
|
margin-left: calc(var(--dmp-thumb-size) / -2);
|
|
712
1049
|
margin-top: calc(var(--dmp-thumb-size) / -2);
|
|
713
1050
|
display: block; overflow: visible; pointer-events: none; z-index: 5;
|
|
714
|
-
/* The knob
|
|
715
|
-
|
|
716
|
-
|
|
1051
|
+
/* The knob is the panel's own grey, ringed in the same grey at partial opacity.
|
|
1052
|
+
Three attempts landed here. Pure black read as a hole (a dark ring on a dark disc
|
|
1053
|
+
on a dark panel is no shape at all). A light grey read as a foreign object — it
|
|
1054
|
+
matched neither the fill nor the track. Using the PANEL's own surface colour keeps
|
|
1055
|
+
the knob in the same family as everything around it, and the partial opacity does
|
|
1056
|
+
the separating: the ring is legible over the pale fill and over the dark track
|
|
1057
|
+
without ever becoming a hard contour.
|
|
1058
|
+
The two values swing with the host stylesheet's own media query, so no JS theme
|
|
1059
|
+
detection is involved. */
|
|
717
1060
|
--dmp-thumb-fill: var(--dsw-alias-bg-module-platform);
|
|
718
|
-
--dmp-thumb-ring:
|
|
1061
|
+
--dmp-thumb-ring: color-mix(in srgb, var(--dsw-alias-bg-module-platform) 45%, transparent);
|
|
719
1062
|
filter: drop-shadow(0 2px 4px color-mix(in srgb, var(--dsw-alias-label-primary) 28%, transparent));
|
|
720
1063
|
transition: left .15s ease-out, transform .12s ease; }
|
|
721
1064
|
.dmp-slider-rail:active .dmp-slider-thumb { transform: scale(1.06); }
|
|
722
1065
|
.dmp-slider-rail:has(.dmp-slider-input:focus-visible) .dmp-slider-thumb {
|
|
723
1066
|
box-shadow: 0 0 0 3px color-mix(in srgb,
|
|
724
|
-
var(--
|
|
1067
|
+
var(--dmp-accent) 35%, transparent); }
|
|
725
1068
|
/* The range input spans from the first stop's reach to the rail's right edge, and
|
|
726
1069
|
its native thumb is the knob's size. That makes its invisible thumb's centre
|
|
727
1070
|
travel exactly from --dmp-stop-left to (width - stop-right) — the same axis the
|
|
@@ -760,8 +1103,15 @@ const CSS = `
|
|
|
760
1103
|
font-family: Montserrat, var(--dsw-font-family-sans, system-ui, sans-serif);
|
|
761
1104
|
white-space: nowrap; cursor: pointer;
|
|
762
1105
|
max-width: 45%; overflow: hidden; text-overflow: ellipsis; }
|
|
763
|
-
|
|
764
|
-
|
|
1106
|
+
/* Every label is centred on its stop.
|
|
1107
|
+
The two end labels used to be re-anchored instead (no transform at the start, a full
|
|
1108
|
+
translate at the end) so their text could not overflow the legend. That kept them
|
|
1109
|
+
inside the box, but it also pulled them off their dots: the last label sat entirely
|
|
1110
|
+
to the LEFT of its stop, which is exactly what makes "Max" look misaligned against
|
|
1111
|
+
the dot above it. The legend already spans the stop axis and the labels are short,
|
|
1112
|
+
so plain centring fits — and every label now shares one rule. */
|
|
1113
|
+
.dmp-slider-tick[data-edge="start"] { transform: translateX(-50%); margin-left: 0; }
|
|
1114
|
+
.dmp-slider-tick[data-edge="end"] { transform: translateX(-50%); margin-left: 0; }
|
|
765
1115
|
.dmp-slider-tick:hover:not(:disabled) { color: var(--dsw-alias-label-secondary);
|
|
766
1116
|
background: var(--dsw-alias-interactive-bg-hover); }
|
|
767
1117
|
.dmp-slider-tick:focus-visible { outline: 2px solid var(--dsw-alias-border-l3); outline-offset: -2px; }
|
|
@@ -770,23 +1120,24 @@ const CSS = `
|
|
|
770
1120
|
Bolding it made the selected label measurably wider and taller-looking than its
|
|
771
1121
|
neighbours, so the labels appeared to change size as the level moved. Keeping one
|
|
772
1122
|
weight keeps every label the same box; the accent colour carries the selection. */
|
|
773
|
-
.dmp-slider-tick-active {
|
|
774
|
-
color: var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary)); }
|
|
1123
|
+
.dmp-slider-tick-active { color: var(--dmp-accent); }
|
|
775
1124
|
.dmp-slider-hint { margin: 5px 0 0; padding-top: 8px;
|
|
776
1125
|
border-top: 1px solid var(--dsw-alias-border-l1);
|
|
777
1126
|
font-size: 11px; line-height: 15px; color: var(--dsw-alias-label-secondary); }
|
|
778
|
-
/* Light surfaces
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
1127
|
+
/* Light surfaces follow the same rule with their own panel colour: white fill, ringed
|
|
1128
|
+
in that same white at partial opacity. On a near-white panel the ring still reads
|
|
1129
|
+
because it sits over the blue fill for most of the travel, and where it crosses the
|
|
1130
|
+
empty track the drop shadow supplies the edge instead. */
|
|
782
1131
|
@media (prefers-color-scheme: light) {
|
|
783
1132
|
.dmp-slider-thumb {
|
|
784
1133
|
--dmp-thumb-fill: var(--dsw-alias-bg-base);
|
|
785
|
-
--dmp-thumb-ring: var(--dsw-alias-
|
|
1134
|
+
--dmp-thumb-ring: color-mix(in srgb, var(--dsw-alias-bg-base) 55%, transparent);
|
|
786
1135
|
}
|
|
787
1136
|
}
|
|
788
1137
|
@media (prefers-reduced-motion: reduce) {
|
|
789
1138
|
.dmp-slider-fill, .dmp-slider-thumb { transition: none; }
|
|
1139
|
+
/* The exhaust is decoration: with motion turned down it is simply not drawn. */
|
|
1140
|
+
.dmp-emit { display: none; }
|
|
790
1141
|
}
|
|
791
1142
|
/* ---------------------------------------------------------------------------
|
|
792
1143
|
Rejected-selection notice
|
|
@@ -1363,6 +1714,14 @@ function createModelPicker(React) {
|
|
|
1363
1714
|
className: 'dmp-panel dmp-panel-slider',
|
|
1364
1715
|
role: 'dialog',
|
|
1365
1716
|
'aria-label': t.effortHeading,
|
|
1717
|
+
// The host lifts the composer's stacking context when one of its own menus
|
|
1718
|
+
// is open: `.composerSeat:has([data-trigger-menu]) { z-index: 9 }`. Without
|
|
1719
|
+
// it the seat stays at z-index 7, and the chat's "jump to latest" button
|
|
1720
|
+
// (z-index 8, a sibling of the seat) paints over this panel no matter what
|
|
1721
|
+
// z-index the panel itself asks for — a child cannot escape its parent's
|
|
1722
|
+
// stacking context. Marking the panel is the supported way to ask for the
|
|
1723
|
+
// same lift.
|
|
1724
|
+
'data-trigger-menu': '',
|
|
1366
1725
|
ref: effortAnchor.panelRef,
|
|
1367
1726
|
style: effortAnchor.style
|
|
1368
1727
|
},
|
|
@@ -1390,6 +1749,8 @@ function createModelPicker(React) {
|
|
|
1390
1749
|
? h('div', {
|
|
1391
1750
|
className: 'dmp-panel',
|
|
1392
1751
|
role: 'listbox',
|
|
1752
|
+
// Same stacking lift as the effort panel: see the comment there.
|
|
1753
|
+
'data-trigger-menu': '',
|
|
1393
1754
|
ref: modelAnchor.panelRef,
|
|
1394
1755
|
style: modelAnchor.style
|
|
1395
1756
|
},
|