llm-switcher 1.2.4 → 1.2.5
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/CHANGELOG.md +7 -0
- package/package.json +1 -1
- package/ui.html +141 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog — LLM Switcher
|
|
2
2
|
|
|
3
|
+
## Release 1.2.5
|
|
4
|
+
|
|
5
|
+
- **Dashboard controls:** The dashboard controls use MIT elements from Uiverse.io galaxy as a base. The CSS gives the name of each author.
|
|
6
|
+
- **Switch and checkbox:** The switch knob moves with a short spring and stretches while you press it. When a switch is off, a ring keeps its track visible. A checkbox shows a tick that grows when you select it.
|
|
7
|
+
- **Feedback:** A toast shows a status dot and a bar for the time until it closes. A styled tooltip replaces the browser tooltip for each item that has a title. Test Connection and Sync Models show a spinner while they run. The dialog fades in.
|
|
8
|
+
- **Accessibility:** A switch and a checkbox show a 2px outline when they have keyboard focus. When the system asks for reduced motion, only the spinner moves.
|
|
9
|
+
|
|
3
10
|
## Release 1.2.4
|
|
4
11
|
|
|
5
12
|
- **Codex blindfold starts on a new machine:** A switch to a Codex profile stopped with "ca.pem is missing" on every new data directory. The certificates came only from `make-certs.sh`, which needs bash and OpenSSL. Now the switcher builds the same set with Node's crypto before it does any other check. It builds a new leaf when the leaf is missing, does not cover the three hosts, or does not match its key. It keeps a CA that still works, because Codex already trusts it.
|
package/package.json
CHANGED
package/ui.html
CHANGED
|
@@ -472,8 +472,10 @@
|
|
|
472
472
|
text-decoration: none;
|
|
473
473
|
font-size: 0.84rem;
|
|
474
474
|
font-weight: 500;
|
|
475
|
-
transition: filter 0.15s, background 0.15s, color 0.15s, border-color 0.15s;
|
|
475
|
+
transition: filter 0.15s, background 0.15s, color 0.15s, border-color 0.15s, transform 0.1s;
|
|
476
476
|
}
|
|
477
|
+
/* Press feedback after Uiverse.io ItsEnigma143 (MIT). */
|
|
478
|
+
button:active:not(:disabled), .btn:active:not(:disabled) { transform: scale(0.97); }
|
|
477
479
|
button.primary, .btn.btn-primary {
|
|
478
480
|
background: var(--accent-strong);
|
|
479
481
|
color: var(--accent-fg);
|
|
@@ -503,6 +505,21 @@
|
|
|
503
505
|
button.icon { min-height: 28px; min-width: 28px; padding: 0.2rem; background: transparent; color: var(--muted); border-radius: 6px; }
|
|
504
506
|
button.icon:hover { color: var(--fg); background: var(--card2); }
|
|
505
507
|
button:disabled, .btn:disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; }
|
|
508
|
+
/* A busy button keeps its full colour: the spinner, not the fade, says it works. */
|
|
509
|
+
button[aria-busy="true"]:disabled { opacity: 1; }
|
|
510
|
+
/* Spinner after Uiverse.io Cybercom682 (MIT): a ring with one open side. */
|
|
511
|
+
button[aria-busy="true"]::before {
|
|
512
|
+
content: "";
|
|
513
|
+
width: 14px;
|
|
514
|
+
height: 14px;
|
|
515
|
+
flex: none;
|
|
516
|
+
box-sizing: border-box;
|
|
517
|
+
border-radius: 50%;
|
|
518
|
+
border: 2px solid currentColor;
|
|
519
|
+
border-right-color: transparent;
|
|
520
|
+
animation: spin 0.7s linear infinite;
|
|
521
|
+
}
|
|
522
|
+
@keyframes spin { to { transform: rotate(1turn); } }
|
|
506
523
|
.btn-sm { min-height: 26px; padding: 0.2rem 0.6rem; font-size: 0.78rem; border-radius: 6px; }
|
|
507
524
|
|
|
508
525
|
/* Inputs */
|
|
@@ -516,7 +533,9 @@
|
|
|
516
533
|
min-height: 36px;
|
|
517
534
|
width: 100%;
|
|
518
535
|
font-size: 0.85rem;
|
|
536
|
+
transition: border-color 0.15s, box-shadow 0.15s;
|
|
519
537
|
}
|
|
538
|
+
input:hover:not(:focus):not(:disabled), select:hover:not(:focus):not(:disabled), textarea:hover:not(:focus):not(:disabled) { border-color: var(--faint); }
|
|
520
539
|
input:focus, select:focus, textarea:focus, button:focus-visible, a:focus-visible {
|
|
521
540
|
outline: none;
|
|
522
541
|
border-color: var(--accent);
|
|
@@ -567,12 +586,50 @@
|
|
|
567
586
|
@media (prefers-reduced-motion: reduce) { .combo-toggle svg { transition: none; } }
|
|
568
587
|
|
|
569
588
|
/* Switch */
|
|
570
|
-
|
|
589
|
+
/* Switch after Uiverse.io iSweat-exe (MIT); the knob stretches while pressed.
|
|
590
|
+
The inset ring keeps the off track visible: --line alone nearly vanishes on a card. */
|
|
591
|
+
.switch { position: relative; width: 36px; height: 20px; flex: none; display: inline-block; vertical-align: middle; }
|
|
571
592
|
.switch input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; min-height: 0; }
|
|
572
|
-
.switch span { position: absolute; inset: 0; border-radius: 999px; background: var(--line); transition: background 0.
|
|
573
|
-
.switch span::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius:
|
|
574
|
-
.switch input:
|
|
593
|
+
.switch span { position: absolute; inset: 0; border-radius: 999px; background: var(--line); box-shadow: inset 0 0 0 1.5px var(--faint); transition: background-color 0.25s, box-shadow 0.25s; pointer-events: none; }
|
|
594
|
+
.switch span::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 999px; background: #ffffff; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); transition: transform 0.25s cubic-bezier(0.3, 1.4, 0.6, 1), width 0.15s; }
|
|
595
|
+
.switch input:active + span::after { width: 20px; }
|
|
596
|
+
.switch input:checked + span { background: var(--accent-strong); box-shadow: none; }
|
|
575
597
|
.switch input:checked + span::after { transform: translateX(16px); }
|
|
598
|
+
.switch input:checked:active + span::after { transform: translateX(12px); }
|
|
599
|
+
.switch input:focus-visible + span, input[type="checkbox"]:not(.switch input):focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
600
|
+
|
|
601
|
+
/* Checkbox after Uiverse.io adamgiebl (MIT): the tick scales in.
|
|
602
|
+
!important beats the inline sizes on the log and 1M boxes. */
|
|
603
|
+
input[type="checkbox"]:not(.switch input) {
|
|
604
|
+
appearance: none;
|
|
605
|
+
width: 16px !important;
|
|
606
|
+
height: 16px !important;
|
|
607
|
+
min-height: 0 !important;
|
|
608
|
+
padding: 0;
|
|
609
|
+
margin: 0;
|
|
610
|
+
flex: none;
|
|
611
|
+
border: 1.5px solid var(--faint);
|
|
612
|
+
border-radius: 4px;
|
|
613
|
+
background: var(--bg);
|
|
614
|
+
cursor: pointer;
|
|
615
|
+
display: inline-grid;
|
|
616
|
+
place-content: center;
|
|
617
|
+
vertical-align: middle;
|
|
618
|
+
transition: background-color 0.15s, border-color 0.15s;
|
|
619
|
+
}
|
|
620
|
+
input[type="checkbox"]:not(.switch input)::before {
|
|
621
|
+
content: "";
|
|
622
|
+
width: 4px;
|
|
623
|
+
height: 8px;
|
|
624
|
+
margin-top: -2px;
|
|
625
|
+
border: solid var(--accent-fg);
|
|
626
|
+
border-width: 0 2px 2px 0;
|
|
627
|
+
transform: rotate(45deg) scale(0);
|
|
628
|
+
transition: transform 0.15s cubic-bezier(0.3, 1.4, 0.6, 1);
|
|
629
|
+
}
|
|
630
|
+
input[type="checkbox"]:not(.switch input):checked { background: var(--accent-strong); border-color: var(--accent-strong); }
|
|
631
|
+
input[type="checkbox"]:not(.switch input):checked::before { transform: rotate(45deg) scale(1); }
|
|
632
|
+
input[type="checkbox"]:not(.switch input):disabled { opacity: 0.45; cursor: default; }
|
|
576
633
|
|
|
577
634
|
/* Modal */
|
|
578
635
|
.overlay {
|
|
@@ -584,7 +641,11 @@
|
|
|
584
641
|
place-items: center;
|
|
585
642
|
padding: 1rem;
|
|
586
643
|
z-index: 50;
|
|
644
|
+
opacity: 1;
|
|
645
|
+
transition: opacity 0.18s;
|
|
587
646
|
}
|
|
647
|
+
.overlay .modal { transition: transform 0.18s cubic-bezier(0.2, 0.9, 0.3, 1.2); }
|
|
648
|
+
@starting-style { .overlay { opacity: 0; } .overlay .modal { transform: scale(0.96) translateY(8px); } }
|
|
588
649
|
.modal {
|
|
589
650
|
background: var(--card);
|
|
590
651
|
border: 1px solid var(--line);
|
|
@@ -675,6 +736,7 @@
|
|
|
675
736
|
}
|
|
676
737
|
|
|
677
738
|
/* Toast */
|
|
739
|
+
/* Toast after Uiverse.io alexruix (MIT): it slides in; the bar shows how long it stays. */
|
|
678
740
|
.toast {
|
|
679
741
|
position: fixed;
|
|
680
742
|
bottom: 1.25rem;
|
|
@@ -684,15 +746,50 @@
|
|
|
684
746
|
color: var(--fg);
|
|
685
747
|
border: 1px solid var(--line);
|
|
686
748
|
box-shadow: var(--shadow);
|
|
687
|
-
padding: 0.
|
|
749
|
+
padding: 0.6rem 1.1rem 0.6rem 0.85rem;
|
|
688
750
|
border-radius: 10px;
|
|
689
751
|
font-size: 0.85rem;
|
|
690
752
|
z-index: 60;
|
|
691
753
|
display: none;
|
|
692
|
-
|
|
754
|
+
align-items: center;
|
|
755
|
+
gap: 0.6rem;
|
|
756
|
+
overflow: hidden;
|
|
757
|
+
max-width: 90vw;
|
|
758
|
+
animation: fadeIn 0.25s cubic-bezier(0.2, 0.9, 0.3, 1.2);
|
|
693
759
|
}
|
|
760
|
+
.toast::before { content: ""; width: 8px; height: 8px; flex: none; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px var(--ok-soft); }
|
|
761
|
+
.toast::after { content: ""; position: absolute; left: 0; bottom: 0; width: 100%; height: 2px; background: var(--accent); transform-origin: left; animation: toastbar 3.2s linear forwards; }
|
|
694
762
|
.toast.err { border-color: var(--err); color: var(--err); }
|
|
695
|
-
|
|
763
|
+
.toast.err::before { background: var(--err); box-shadow: 0 0 0 3px var(--err-soft); }
|
|
764
|
+
.toast.err::after { background: var(--err); }
|
|
765
|
+
@keyframes fadeIn { from { opacity: 0; transform: translate(-50%, 12px); } to { opacity: 1; transform: translate(-50%, 0); } }
|
|
766
|
+
@keyframes toastbar { to { transform: scaleX(0); } }
|
|
767
|
+
/* Tooltip after Uiverse.io elijahgummer (MIT): a short fade and rise that
|
|
768
|
+
replaces the native title bubble. */
|
|
769
|
+
.tip {
|
|
770
|
+
position: fixed;
|
|
771
|
+
z-index: 70;
|
|
772
|
+
pointer-events: none;
|
|
773
|
+
background: var(--fg);
|
|
774
|
+
color: var(--bg);
|
|
775
|
+
font-size: 0.72rem;
|
|
776
|
+
font-weight: 500;
|
|
777
|
+
padding: 0.3rem 0.55rem;
|
|
778
|
+
border-radius: 6px;
|
|
779
|
+
white-space: nowrap;
|
|
780
|
+
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.25);
|
|
781
|
+
opacity: 1;
|
|
782
|
+
translate: 0 0;
|
|
783
|
+
transition: opacity 0.15s, translate 0.2s cubic-bezier(0.3, 1.4, 0.6, 1), display 0.15s;
|
|
784
|
+
transition-behavior: allow-discrete;
|
|
785
|
+
}
|
|
786
|
+
.tip[hidden] { display: none; opacity: 0; translate: 0 4px; }
|
|
787
|
+
@starting-style { .tip { opacity: 0; translate: 0 4px; } }
|
|
788
|
+
@media (prefers-reduced-motion: reduce) {
|
|
789
|
+
*, ::before, ::after { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }
|
|
790
|
+
button[aria-busy="true"]::before { animation-duration: 1.5s !important; }
|
|
791
|
+
.toast::after { animation-duration: 3.2s !important; }
|
|
792
|
+
}
|
|
696
793
|
|
|
697
794
|
/* Test Result Box in Modal */
|
|
698
795
|
.test-result-box {
|
|
@@ -1117,7 +1214,8 @@
|
|
|
1117
1214
|
</div>
|
|
1118
1215
|
</div>
|
|
1119
1216
|
|
|
1120
|
-
<div id="toast" class="toast"></div>
|
|
1217
|
+
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
|
1218
|
+
<div id="tip" class="tip" role="tooltip" hidden></div>
|
|
1121
1219
|
|
|
1122
1220
|
<script>
|
|
1123
1221
|
// State & Globals
|
|
@@ -1190,11 +1288,40 @@
|
|
|
1190
1288
|
if (!toast) return;
|
|
1191
1289
|
toast.textContent = msg;
|
|
1192
1290
|
toast.className = 'toast' + (isErr ? ' err' : '');
|
|
1193
|
-
|
|
1291
|
+
// Hiding first and reading a layout value restarts the entry animation and the timer bar.
|
|
1292
|
+
toast.style.display = 'none';
|
|
1293
|
+
void toast.offsetWidth;
|
|
1294
|
+
toast.style.display = 'flex';
|
|
1194
1295
|
clearTimeout(toastTimeout);
|
|
1195
1296
|
toastTimeout = setTimeout(() => { toast.style.display = 'none'; }, 3200);
|
|
1196
1297
|
}
|
|
1197
1298
|
|
|
1299
|
+
// The title moves to data-tip on first hover, so the native bubble does not show as well.
|
|
1300
|
+
// An input keeps its title: the browser shows it in the validation message.
|
|
1301
|
+
const tip = document.getElementById('tip');
|
|
1302
|
+
function showTip(e) {
|
|
1303
|
+
const n = e.target.closest && e.target.closest('[title]:not(input), [data-tip]');
|
|
1304
|
+
if (!n) return;
|
|
1305
|
+
if (n.title) {
|
|
1306
|
+
n.dataset.tip = n.title;
|
|
1307
|
+
n.removeAttribute('title');
|
|
1308
|
+
if (!n.hasAttribute('aria-label')) n.setAttribute(n.textContent.trim() ? 'aria-description' : 'aria-label', n.dataset.tip);
|
|
1309
|
+
}
|
|
1310
|
+
tip.textContent = n.dataset.tip;
|
|
1311
|
+
tip.hidden = false;
|
|
1312
|
+
const r = n.getBoundingClientRect(), w = tip.offsetWidth, h = tip.offsetHeight;
|
|
1313
|
+
tip.style.left = Math.max(8, Math.min(r.left + r.width / 2 - w / 2, innerWidth - w - 8)) + 'px';
|
|
1314
|
+
tip.style.top = (r.top - h - 8 < 8 ? r.bottom + 8 : r.top - h - 8) + 'px';
|
|
1315
|
+
}
|
|
1316
|
+
const hideTip = () => { tip.hidden = true; };
|
|
1317
|
+
document.addEventListener('pointerover', showTip);
|
|
1318
|
+
document.addEventListener('focusin', showTip);
|
|
1319
|
+
document.addEventListener('pointerout', e => {
|
|
1320
|
+
const n = e.target.closest && e.target.closest('[data-tip]');
|
|
1321
|
+
if (n && !n.contains(e.relatedTarget)) hideTip();
|
|
1322
|
+
});
|
|
1323
|
+
for (const ev of ['focusout', 'click', 'scroll']) addEventListener(ev, hideTip, true);
|
|
1324
|
+
|
|
1198
1325
|
// Modal Control
|
|
1199
1326
|
function openProfileModal(isNew = false) {
|
|
1200
1327
|
const modal = document.getElementById('profile-modal-overlay');
|
|
@@ -1990,6 +2117,7 @@
|
|
|
1990
2117
|
|
|
1991
2118
|
btn.innerText = 'Testing...';
|
|
1992
2119
|
btn.disabled = true;
|
|
2120
|
+
btn.setAttribute('aria-busy', 'true');
|
|
1993
2121
|
box.style.display = 'none';
|
|
1994
2122
|
|
|
1995
2123
|
try {
|
|
@@ -2040,6 +2168,7 @@
|
|
|
2040
2168
|
} finally {
|
|
2041
2169
|
btn.innerText = 'Test Connection';
|
|
2042
2170
|
btn.disabled = false;
|
|
2171
|
+
btn.removeAttribute('aria-busy');
|
|
2043
2172
|
}
|
|
2044
2173
|
}
|
|
2045
2174
|
|
|
@@ -2324,7 +2453,7 @@
|
|
|
2324
2453
|
|
|
2325
2454
|
async function refreshCatalogUpstream() {
|
|
2326
2455
|
const btn = document.getElementById('btn-sync-catalog');
|
|
2327
|
-
if (btn) { btn.disabled = true; btn.textContent = 'Syncing...'; }
|
|
2456
|
+
if (btn) { btn.disabled = true; btn.setAttribute('aria-busy', 'true'); btn.textContent = 'Syncing...'; }
|
|
2328
2457
|
showToast('Refreshing model catalog from upstream...');
|
|
2329
2458
|
try {
|
|
2330
2459
|
const res = await api('/api/catalog/refresh', { method: 'POST' });
|
|
@@ -2338,7 +2467,7 @@
|
|
|
2338
2467
|
} catch (err) {
|
|
2339
2468
|
showToast('Network error syncing catalog', true);
|
|
2340
2469
|
} finally {
|
|
2341
|
-
if (btn) { btn.disabled = false; btn.textContent = '⚡ Sync Models with Upstream'; }
|
|
2470
|
+
if (btn) { btn.disabled = false; btn.removeAttribute('aria-busy'); btn.textContent = '⚡ Sync Models with Upstream'; }
|
|
2342
2471
|
}
|
|
2343
2472
|
}
|
|
2344
2473
|
|