@huai_xin/dsh-model-picker 0.2.0 → 0.2.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/client.js +194 -25
- package/package.json +1 -1
package/client.js
CHANGED
|
@@ -332,6 +332,37 @@ 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
|
+
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 }
|
|
364
|
+
]
|
|
365
|
+
|
|
335
366
|
return function EffortSlider({ efforts, activeId, disabled, onChange, t, caption }) {
|
|
336
367
|
const count = efforts.length
|
|
337
368
|
const incoming = Math.max(0, efforts.findIndex((effort) => effort.id === activeId))
|
|
@@ -365,7 +396,11 @@ function createEffortSlider(React) {
|
|
|
365
396
|
const position = (at) => count <= 1 ? '50%' : `${(at / (count - 1)) * 100}%`
|
|
366
397
|
|
|
367
398
|
return h('div', {
|
|
368
|
-
|
|
399
|
+
// `dmp-slider-dragging` drops the left transition on the knob, the fill and
|
|
400
|
+
// the emitter together. Without it the knob eases to its new stop over 150ms
|
|
401
|
+
// while a transition-free emitter jumps there at once, so the particles left
|
|
402
|
+
// from a position the knob had not reached yet.
|
|
403
|
+
className: `dmp-slider${dragging === null ? '' : ' dmp-slider-dragging'}`,
|
|
369
404
|
'aria-disabled': disabled === true,
|
|
370
405
|
style: { '--dmp-effort-at': position(index) }
|
|
371
406
|
},
|
|
@@ -378,6 +413,40 @@ function createEffortSlider(React) {
|
|
|
378
413
|
h('span', { className: 'dmp-slider-value' }, activeName)),
|
|
379
414
|
|
|
380
415
|
h('div', { className: 'dmp-slider-rail' },
|
|
416
|
+
/**
|
|
417
|
+
* The rail's outline, drawn as vector art.
|
|
418
|
+
*
|
|
419
|
+
* This started as a `box-shadow` inset, which is a RASTER edge: at 1px it
|
|
420
|
+
* lands on the pixel grid but reads flat, and at 1.5px it falls between
|
|
421
|
+
* pixels and smears into a grey band. The knob's ring never had that problem
|
|
422
|
+
* because it is an SVG stroke — a vector edge, crisp at any scale.
|
|
423
|
+
*
|
|
424
|
+
* So the outline uses the same primitive as the knob: a `<rect>` with a
|
|
425
|
+
* stroked edge, sized to the rail and scaled to it by `preserveAspectRatio`
|
|
426
|
+
* being off. `vector-effect: non-scaling-stroke` keeps the stroke exactly
|
|
427
|
+
* 1px however the rect is stretched, so the line stays hairline-crisp instead
|
|
428
|
+
* of being squashed by the horizontal stretch. `rx` is a percentage of the
|
|
429
|
+
* half-height, matching the rail's own rounded ends.
|
|
430
|
+
*/
|
|
431
|
+
h('svg', {
|
|
432
|
+
className: 'dmp-slider-outline',
|
|
433
|
+
'aria-hidden': true,
|
|
434
|
+
preserveAspectRatio: 'none'
|
|
435
|
+
},
|
|
436
|
+
h('rect', {
|
|
437
|
+
// Half the stroke on each axis: an SVG stroke straddles the path, so a 2px
|
|
438
|
+
// line on the exact edge would be half-clipped by the rail's clip-path.
|
|
439
|
+
x: 1,
|
|
440
|
+
y: 1,
|
|
441
|
+
width: 'calc(100% - 2px)',
|
|
442
|
+
height: 'calc(100% - 2px)',
|
|
443
|
+
rx: 14,
|
|
444
|
+
ry: 14,
|
|
445
|
+
fill: 'none',
|
|
446
|
+
stroke: 'var(--dsw-alias-border-l3)',
|
|
447
|
+
strokeWidth: 2,
|
|
448
|
+
vectorEffect: 'non-scaling-stroke'
|
|
449
|
+
})),
|
|
381
450
|
/**
|
|
382
451
|
* The moving parts live in an INSET track.
|
|
383
452
|
*
|
|
@@ -392,6 +461,28 @@ function createEffortSlider(React) {
|
|
|
392
461
|
*/
|
|
393
462
|
h('div', { className: 'dmp-slider-track' },
|
|
394
463
|
h('div', { className: 'dmp-slider-fill', 'aria-hidden': true }),
|
|
464
|
+
/**
|
|
465
|
+
* The exhaust.
|
|
466
|
+
*
|
|
467
|
+
* An empty box pinned at the knob's position (`--dmp-effort-at`), carrying
|
|
468
|
+
* the particle squares. It is a SIBLING of the knob inside the same inset
|
|
469
|
+
* track, so it shares the knob's coordinate space and its transition —
|
|
470
|
+
* which is what keeps the spawn point under the knob while it moves.
|
|
471
|
+
* The rail's clip-path trims whatever drifts past the end cap.
|
|
472
|
+
*/
|
|
473
|
+
h('span', { className: 'dmp-emit', 'aria-hidden': true },
|
|
474
|
+
EMIT.map((particle, at) => h('i', {
|
|
475
|
+
key: at,
|
|
476
|
+
className: 'dmp-emit-p',
|
|
477
|
+
style: {
|
|
478
|
+
'--i': at,
|
|
479
|
+
'--dx': `${particle.dx}px`,
|
|
480
|
+
'--dy0': `${particle.dy0}px`,
|
|
481
|
+
'--dy': `${particle.dy}px`,
|
|
482
|
+
'--sz': `${particle.sz}px`,
|
|
483
|
+
'--rot': `${particle.rot}deg`
|
|
484
|
+
}
|
|
485
|
+
}))),
|
|
395
486
|
h('div', { className: 'dmp-slider-dots', 'aria-hidden': true },
|
|
396
487
|
efforts.map((effort, at) => h('span', {
|
|
397
488
|
key: effort.id,
|
|
@@ -646,18 +737,31 @@ const CSS = `
|
|
|
646
737
|
The fill and the active label take the host's business-primary token — the same
|
|
647
738
|
accent DSH uses for its own interactive highlights — so the control recolours
|
|
648
739
|
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
|
-
|
|
740
|
+
/* The knob is the rail's full height (28px). Both ends keep the knob clear of the
|
|
741
|
+
rail's cap, but by DIFFERENT amounts — that asymmetry is the requested behaviour,
|
|
742
|
+
not an oversight:
|
|
743
|
+
--dmp-stop-left 28px the first stop sits a full knob diameter in from the
|
|
744
|
+
left cap, so the knob stops well short of that end;
|
|
745
|
+
--dmp-stop-right 16px the last stop sits just past the knob's radius (14), so a
|
|
746
|
+
hairline of track shows beyond the knob at the top level.
|
|
747
|
+
The right gap is deliberately the same weight as the knob's own ring: at 14 the
|
|
748
|
+
knob landed exactly on the cap and the two outlines met, which read as a smudge
|
|
749
|
+
rather than as a stop. The dots, the labels, the fill and the native input's
|
|
750
|
+
travel all derive from these two numbers, which is why they are variables rather
|
|
751
|
+
than four copies of the same value. */
|
|
658
752
|
.dmp-slider { --dmp-rail-height: 28px; --dmp-thumb-size: 28px;
|
|
659
|
-
--dmp-stop-left: 28px; --dmp-stop-right:
|
|
660
|
-
/*
|
|
753
|
+
--dmp-stop-left: 28px; --dmp-stop-right: 16px; }
|
|
754
|
+
/* The accent.
|
|
755
|
+
The alias token is one step too pale on a dark panel: in the dark theme
|
|
756
|
+
--dsw-alias-state-business-primary resolves to the deepseek-400 step, which reads
|
|
757
|
+
as washed out against the panel surface. The ramp's next step down, deepseek-450,
|
|
758
|
+
is the same hue with the weight the bar needs. The alias token stays as the
|
|
759
|
+
fallback so a future ramp change still lands on something.
|
|
760
|
+
The preview pages carry these same values, which is why a preview can look right
|
|
761
|
+
while the app looks pale — the demo used to hard-code a blue of its own. */
|
|
762
|
+
.dmp-slider { --dmp-accent: var(--dsw-static-deepseek-450,
|
|
763
|
+
var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary))); }
|
|
764
|
+
/* Radius is a LITERAL 15px on a 28px-tall rail — deliberately ONE more than half the
|
|
661
765
|
height, so the ends curve a touch harder than a true semicircle and read as round
|
|
662
766
|
rather than as pillowed caps. Anything below half looks square; this is the
|
|
663
767
|
user-tuned value.
|
|
@@ -673,6 +777,14 @@ const CSS = `
|
|
|
673
777
|
margin: 12px 0 4px; border-radius: 15px; flex: none;
|
|
674
778
|
clip-path: inset(0 round 15px);
|
|
675
779
|
background: color-mix(in srgb, var(--dsw-alias-label-primary) 12%, transparent); }
|
|
780
|
+
/* The outline: vector, like the knob's ring.
|
|
781
|
+
An inset box-shadow is a raster edge — 1px reads flat and 1.5px falls between
|
|
782
|
+
pixels and smears. This SVG stretches to the rail with preserveAspectRatio none
|
|
783
|
+
and keeps its stroke at exactly 1px through vector-effect, so the line is as
|
|
784
|
+
crisp as the knob's circle. It sits above the fill so it stays visible across the
|
|
785
|
+
blue, and pointer-events none keeps the range input reachable underneath. */
|
|
786
|
+
.dmp-slider-outline { position: absolute; inset: 0; width: 100%; height: 100%;
|
|
787
|
+
pointer-events: none; z-index: 7; overflow: visible; }
|
|
676
788
|
/* The stop axis. Its ends are the two stop offsets, which is why 0% is the first
|
|
677
789
|
stop and 100% the last — the knob's travel, the dots, the labels and the fill all
|
|
678
790
|
read from this one box. */
|
|
@@ -688,20 +800,59 @@ const CSS = `
|
|
|
688
800
|
.dmp-slider-fill { position: absolute; top: 0; height: var(--dmp-rail-height);
|
|
689
801
|
left: calc(var(--dmp-stop-left) * -1);
|
|
690
802
|
width: calc(var(--dmp-effort-at) + var(--dmp-stop-left));
|
|
691
|
-
background: var(--
|
|
803
|
+
background: var(--dmp-accent);
|
|
692
804
|
z-index: 1; transition: width .15s ease-out; pointer-events: none; }
|
|
693
805
|
.dmp-slider-dots { position: absolute; top: 0; bottom: 0; left: 0; right: 0;
|
|
694
806
|
pointer-events: none; z-index: 2; }
|
|
695
|
-
/* Stops stay SMALL and GREY
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
807
|
+
/* Stops stay SMALL and GREY, and they are only visible AHEAD of the knob: the blue
|
|
808
|
+
fill already says "you are this far", so a dot sitting on it read as dust on the
|
|
809
|
+
colour. Unreached stops keep working as tick marks on the empty part of the track,
|
|
810
|
+
which is where they still carry information. */
|
|
699
811
|
.dmp-slider-dot { position: absolute; top: 50%; width: 5px; height: 5px;
|
|
700
812
|
border-radius: 50%; transform: translate(-50%, -50%);
|
|
701
813
|
background: var(--dsw-alias-label-tertiary, currentColor); opacity: .55;
|
|
702
|
-
transition: opacity .15s ease-out
|
|
703
|
-
|
|
704
|
-
|
|
814
|
+
transition: opacity .15s ease-out; }
|
|
815
|
+
.dmp-slider-dot-lit { opacity: 0; }
|
|
816
|
+
/* The exhaust: a zero-size point pinned to the knob, carrying the particles.
|
|
817
|
+
It sits ABOVE the fill and BELOW the knob, so squares are born behind the disc and
|
|
818
|
+
drift out from under it. Its left offset carries the SAME transition as the knob — that
|
|
819
|
+
is the whole alignment contract: both are driven by --dmp-effort-at, so as long as
|
|
820
|
+
they animate with the same duration and easing the spawn point stays under the
|
|
821
|
+
knob while it travels. (A drag drops the transition on both; see the dragging
|
|
822
|
+
rule below, which is what fixed particles appearing ahead of the knob.) */
|
|
823
|
+
.dmp-emit { position: absolute; top: 50%; left: var(--dmp-effort-at);
|
|
824
|
+
width: 0; height: 0; z-index: 3; pointer-events: none;
|
|
825
|
+
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. */
|
|
828
|
+
.dmp-emit-p { position: absolute; left: 0; top: 0;
|
|
829
|
+
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);
|
|
833
|
+
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;
|
|
839
|
+
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; }
|
|
844
|
+
@keyframes dmp-emit {
|
|
845
|
+
0% { transform: translate(-50%, -50%) translate(0, var(--dy0)) scale(1); opacity: 0; }
|
|
846
|
+
14% { opacity: .95; }
|
|
847
|
+
70% { opacity: .5; }
|
|
848
|
+
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; } }
|
|
851
|
+
/* A drag is direct manipulation: the knob, the fill and the emitter all snap to the
|
|
852
|
+
pointer instead of easing, which keeps the particles born exactly at the knob. */
|
|
853
|
+
.dmp-slider-dragging .dmp-slider-thumb,
|
|
854
|
+
.dmp-slider-dragging .dmp-slider-fill,
|
|
855
|
+
.dmp-slider-dragging .dmp-emit { transition: none; }
|
|
705
856
|
/* The knob fills the rail's height and is drawn as an SVG, so no inherited
|
|
706
857
|
border-radius, padding or box-sizing rule can square it off. Its size comes from
|
|
707
858
|
--dmp-thumb-size; the stop offsets and the input's travel are derived from the
|
|
@@ -721,7 +872,7 @@ const CSS = `
|
|
|
721
872
|
.dmp-slider-rail:active .dmp-slider-thumb { transform: scale(1.06); }
|
|
722
873
|
.dmp-slider-rail:has(.dmp-slider-input:focus-visible) .dmp-slider-thumb {
|
|
723
874
|
box-shadow: 0 0 0 3px color-mix(in srgb,
|
|
724
|
-
var(--
|
|
875
|
+
var(--dmp-accent) 35%, transparent); }
|
|
725
876
|
/* The range input spans from the first stop's reach to the rail's right edge, and
|
|
726
877
|
its native thumb is the knob's size. That makes its invisible thumb's centre
|
|
727
878
|
travel exactly from --dmp-stop-left to (width - stop-right) — the same axis the
|
|
@@ -760,8 +911,15 @@ const CSS = `
|
|
|
760
911
|
font-family: Montserrat, var(--dsw-font-family-sans, system-ui, sans-serif);
|
|
761
912
|
white-space: nowrap; cursor: pointer;
|
|
762
913
|
max-width: 45%; overflow: hidden; text-overflow: ellipsis; }
|
|
763
|
-
|
|
764
|
-
|
|
914
|
+
/* Every label is centred on its stop.
|
|
915
|
+
The two end labels used to be re-anchored instead (no transform at the start, a full
|
|
916
|
+
translate at the end) so their text could not overflow the legend. That kept them
|
|
917
|
+
inside the box, but it also pulled them off their dots: the last label sat entirely
|
|
918
|
+
to the LEFT of its stop, which is exactly what makes "Max" look misaligned against
|
|
919
|
+
the dot above it. The legend already spans the stop axis and the labels are short,
|
|
920
|
+
so plain centring fits — and every label now shares one rule. */
|
|
921
|
+
.dmp-slider-tick[data-edge="start"] { transform: translateX(-50%); margin-left: 0; }
|
|
922
|
+
.dmp-slider-tick[data-edge="end"] { transform: translateX(-50%); margin-left: 0; }
|
|
765
923
|
.dmp-slider-tick:hover:not(:disabled) { color: var(--dsw-alias-label-secondary);
|
|
766
924
|
background: var(--dsw-alias-interactive-bg-hover); }
|
|
767
925
|
.dmp-slider-tick:focus-visible { outline: 2px solid var(--dsw-alias-border-l3); outline-offset: -2px; }
|
|
@@ -770,8 +928,7 @@ const CSS = `
|
|
|
770
928
|
Bolding it made the selected label measurably wider and taller-looking than its
|
|
771
929
|
neighbours, so the labels appeared to change size as the level moved. Keeping one
|
|
772
930
|
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)); }
|
|
931
|
+
.dmp-slider-tick-active { color: var(--dmp-accent); }
|
|
775
932
|
.dmp-slider-hint { margin: 5px 0 0; padding-top: 8px;
|
|
776
933
|
border-top: 1px solid var(--dsw-alias-border-l1);
|
|
777
934
|
font-size: 11px; line-height: 15px; color: var(--dsw-alias-label-secondary); }
|
|
@@ -787,6 +944,8 @@ const CSS = `
|
|
|
787
944
|
}
|
|
788
945
|
@media (prefers-reduced-motion: reduce) {
|
|
789
946
|
.dmp-slider-fill, .dmp-slider-thumb { transition: none; }
|
|
947
|
+
/* The exhaust is decoration: with motion turned down it is simply not drawn. */
|
|
948
|
+
.dmp-emit { display: none; }
|
|
790
949
|
}
|
|
791
950
|
/* ---------------------------------------------------------------------------
|
|
792
951
|
Rejected-selection notice
|
|
@@ -1363,6 +1522,14 @@ function createModelPicker(React) {
|
|
|
1363
1522
|
className: 'dmp-panel dmp-panel-slider',
|
|
1364
1523
|
role: 'dialog',
|
|
1365
1524
|
'aria-label': t.effortHeading,
|
|
1525
|
+
// The host lifts the composer's stacking context when one of its own menus
|
|
1526
|
+
// is open: `.composerSeat:has([data-trigger-menu]) { z-index: 9 }`. Without
|
|
1527
|
+
// it the seat stays at z-index 7, and the chat's "jump to latest" button
|
|
1528
|
+
// (z-index 8, a sibling of the seat) paints over this panel no matter what
|
|
1529
|
+
// z-index the panel itself asks for — a child cannot escape its parent's
|
|
1530
|
+
// stacking context. Marking the panel is the supported way to ask for the
|
|
1531
|
+
// same lift.
|
|
1532
|
+
'data-trigger-menu': '',
|
|
1366
1533
|
ref: effortAnchor.panelRef,
|
|
1367
1534
|
style: effortAnchor.style
|
|
1368
1535
|
},
|
|
@@ -1390,6 +1557,8 @@ function createModelPicker(React) {
|
|
|
1390
1557
|
? h('div', {
|
|
1391
1558
|
className: 'dmp-panel',
|
|
1392
1559
|
role: 'listbox',
|
|
1560
|
+
// Same stacking lift as the effort panel: see the comment there.
|
|
1561
|
+
'data-trigger-menu': '',
|
|
1393
1562
|
ref: modelAnchor.panelRef,
|
|
1394
1563
|
style: modelAnchor.style
|
|
1395
1564
|
},
|