llm-switcher 1.3.4 → 1.3.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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog — LLM Switcher
2
2
 
3
+ ## Release 1.3.5
4
+
5
+ - **Dashboard motion:** The switch, the checkbox, the dialog, the toast and the tooltip used easing curves that overshoot and bounce back. They now use one smooth ease-out curve (`--ease-out`). The durations did not change.
6
+ - **Dashboard elevation:** The dialog, the toast and the model list used a wide 50 px shadow on top of their border. The shadow is now short and tight, so the border defines the edge, in both themes.
7
+ - **Dialog tabs on a phone:** At 390 px wide, the profile dialog cut off the **Model Slots** tab, and the tab bar hides its scrollbar. On a narrow screen the tabs now have less padding, and they wrap if they still do not fit.
8
+ - **API key field:** The field has `autocomplete="off"`, so a password manager does not offer to save the key.
9
+ - **Update now that times out:** The message "The new gateway did not answer" now also says what the last check saw: the gateway from before the update, an HTTP status, or a network error.
10
+
3
11
  ## Release 1.3.4
4
12
 
5
13
  - **The dashboard page carries no token:** `/ui` sent `admin.token` in the page, and any program on the machine can load `/ui`. The page now gets the token only from the `#token=` of the private launcher that `switch ui` opens (a file with mode 0600). The tab keeps it in `sessionStorage`, so it does not stay in the browser after the tab closes. A tab opened at `/ui` directly says to use `switch ui`. `switch status` and the gateway start line say the same.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-switcher",
3
- "version": "1.3.4",
3
+ "version": "1.3.5",
4
4
  "description": "Zero-dependency multi-protocol edge gateway & provider switcher for Claude Code and Codex, with OpenAI-compatible, Anthropic and Vertex upstreams",
5
5
  "keywords": [
6
6
  "llm",
@@ -776,6 +776,23 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
776
776
  }
777
777
  });
778
778
 
779
+ it('shows every dialog tab whole on a phone', async () => {
780
+ await page.resize(390, 844);
781
+ try {
782
+ await editProfile('intact-claude');
783
+ await page.waitFor('document.getElementById("profile-modal-overlay").style.display === "grid"', 'the dialog');
784
+ const cut = await page.evaluate(`(() => {
785
+ const bar = document.querySelector('#profile-form .tabs').getBoundingClientRect();
786
+ return [...document.querySelectorAll('#profile-form .tabs .tab-btn')]
787
+ .filter(b => { const r = b.getBoundingClientRect(); return r.left < bar.left - 0.5 || r.right > bar.right + 0.5; })
788
+ .map(b => b.textContent.trim());
789
+ })()`);
790
+ assert.deepEqual(cut, [], 'these tabs are cut off by the tab bar at 390 px');
791
+ } finally {
792
+ await page.resize(1280, 900);
793
+ }
794
+ });
795
+
779
796
  it('does not let a long profile name break the dialog header', async () => {
780
797
  const cfg = fx.config();
781
798
  cfg.profiles['intact-claude'].name = 'N'.repeat(300);
@@ -186,7 +186,7 @@ test('switch update asks the running gateway and waits for the new one', { timeo
186
186
  test('the dashboard Update now button installs the release and reloads on the new gateway', { timeout: 90000, skip: skipReason() }, async (t) => {
187
187
  const fx = await fixture(t, { registryUrl: await registry(t, '9.9.12') });
188
188
  await fx.start();
189
- await fx.waitFor(fx.pid, 'the gateway');
189
+ const firstPid = await fx.waitFor(fx.pid, 'the gateway');
190
190
  fx.release('9.9.12');
191
191
  const browser = await launchBrowser();
192
192
  t.after(() => browser.close());
@@ -200,7 +200,8 @@ test('the dashboard Update now button installs the release and reloads on the ne
200
200
  } catch (err) {
201
201
  // A CI runner cannot be watched: put what the gateway and the page saw into the failure.
202
202
  const stage = await page.evaluate(`document.getElementById('update-status')?.textContent || ''`).catch(() => '(page gone)');
203
- err.message += `\nupdate status on the page: ${stage}\ngateway log:\n${fx.log()}\npage problems: ${JSON.stringify(page.problems)}`;
203
+ // The pid seen from this process tells a gateway that does not answer from a page that cannot reach it.
204
+ err.message += `\nupdate status on the page: ${stage}\npid on /health from the test: ${await fx.pid()} (first gateway ${firstPid})\ngateway log:\n${fx.log()}\npage problems: ${JSON.stringify(page.problems)}`;
204
205
  throw err;
205
206
  }
206
207
  assert.equal(await page.evaluate(`document.getElementById('update-notice').hidden`), true);
package/ui.html CHANGED
@@ -17,6 +17,7 @@
17
17
  <style>
18
18
  :root {
19
19
  color-scheme: dark;
20
+ --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
20
21
  --bg: #0c1017;
21
22
  --side: #10151e;
22
23
  --card: #141a25;
@@ -37,7 +38,7 @@
37
38
  --err: #fb7185;
38
39
  --err-soft: rgba(251, 113, 133, 0.12);
39
40
  --code: #5eead4;
40
- --shadow: 0 20px 50px rgba(0, 0, 0, 0.55);
41
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 12px -6px rgba(0, 0, 0, 0.55);
41
42
 
42
43
  /* Compatibility aliases */
43
44
  --surface: var(--side);
@@ -89,7 +90,7 @@
89
90
  --err: #be123c;
90
91
  --err-soft: rgba(190, 18, 60, 0.08);
91
92
  --code: #0f766e;
92
- --shadow: 0 20px 50px rgba(20, 26, 37, 0.14);
93
+ --shadow: 0 1px 2px rgba(20, 26, 37, 0.08), 0 8px 12px -6px rgba(20, 26, 37, 0.16);
93
94
  --border-strong: #cbd5e1;
94
95
  }
95
96
  }
@@ -115,7 +116,7 @@
115
116
  --err: #be123c;
116
117
  --err-soft: rgba(190, 18, 60, 0.08);
117
118
  --code: #0f766e;
118
- --shadow: 0 20px 50px rgba(20, 26, 37, 0.14);
119
+ --shadow: 0 1px 2px rgba(20, 26, 37, 0.08), 0 8px 12px -6px rgba(20, 26, 37, 0.16);
119
120
  --border-strong: #cbd5e1;
120
121
  }
121
122
 
@@ -593,7 +594,7 @@
593
594
  .switch { position: relative; width: 36px; height: 20px; flex: none; display: inline-block; vertical-align: middle; }
594
595
  .switch input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; min-height: 0; }
595
596
  .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; }
596
- .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; }
597
+ .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 var(--ease-out), width 0.15s; }
597
598
  .switch input:active + span::after { width: 20px; }
598
599
  .switch input:checked + span { background: var(--accent-strong); box-shadow: none; }
599
600
  .switch input:checked + span::after { transform: translateX(16px); }
@@ -627,7 +628,7 @@
627
628
  border: solid var(--accent-fg);
628
629
  border-width: 0 2px 2px 0;
629
630
  transform: rotate(45deg) scale(0);
630
- transition: transform 0.15s cubic-bezier(0.3, 1.4, 0.6, 1);
631
+ transition: transform 0.15s var(--ease-out);
631
632
  }
632
633
  input[type="checkbox"]:not(.switch input):checked { background: var(--accent-strong); border-color: var(--accent-strong); }
633
634
  input[type="checkbox"]:not(.switch input):checked::before { transform: rotate(45deg) scale(1); }
@@ -646,7 +647,7 @@
646
647
  opacity: 1;
647
648
  transition: opacity 0.18s;
648
649
  }
649
- .overlay .modal { transition: transform 0.18s cubic-bezier(0.2, 0.9, 0.3, 1.2); }
650
+ .overlay .modal { transition: transform 0.18s var(--ease-out); }
650
651
  @starting-style { .overlay { opacity: 0; } .overlay .modal { transform: scale(0.96) translateY(8px); } }
651
652
  .modal {
652
653
  background: var(--card);
@@ -693,6 +694,7 @@
693
694
  scrollbar-width: none;
694
695
  }
695
696
  .tabs::-webkit-scrollbar { display: none; }
697
+ .tabs-wrap { padding: 0.85rem 1.25rem 0.5rem; background: var(--card2); }
696
698
  .tabs button.tab-btn {
697
699
  padding: 0.55rem 0.95rem;
698
700
  color: var(--muted);
@@ -712,6 +714,13 @@
712
714
  border-bottom-color: var(--accent);
713
715
  font-weight: 600;
714
716
  }
717
+ /* A phone has no room for all three tabs at full padding, and the bar hides its scrollbar:
718
+ tighten them, and wrap rather than hide a tab when even that is not enough. */
719
+ @media (max-width: 480px) {
720
+ .tabs-wrap { padding-inline: 1rem; }
721
+ .tabs { flex-wrap: wrap; }
722
+ .tabs button.tab-btn { padding-inline: 0.6rem; }
723
+ }
715
724
 
716
725
  /* Log Items */
717
726
  .log-item {
@@ -757,7 +766,7 @@
757
766
  gap: 0.6rem;
758
767
  overflow: hidden;
759
768
  max-width: 90vw;
760
- animation: fadeIn 0.25s cubic-bezier(0.2, 0.9, 0.3, 1.2);
769
+ animation: fadeIn 0.25s var(--ease-out);
761
770
  }
762
771
  .toast::before { content: ""; width: 8px; height: 8px; flex: none; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px var(--ok-soft); }
763
772
  .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; }
@@ -782,7 +791,7 @@
782
791
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.25);
783
792
  opacity: 1;
784
793
  translate: 0 0;
785
- transition: opacity 0.15s, translate 0.2s cubic-bezier(0.3, 1.4, 0.6, 1), display 0.15s;
794
+ transition: opacity 0.15s, translate 0.2s var(--ease-out), display 0.15s;
786
795
  transition-behavior: allow-discrete;
787
796
  }
788
797
  .tip[hidden] { display: none; opacity: 0; translate: 0 4px; }
@@ -1123,7 +1132,7 @@
1123
1132
 
1124
1133
  <form id="profile-form" onsubmit="saveProfile(event)" onkeydown="blockImplicitSubmit(event)" oninput="formDirty = true" onchange="formDirty = true" style="display:flex; flex-direction:column; overflow:hidden;">
1125
1134
  <!-- Modal Inset Tabs -->
1126
- <div style="padding:0.85rem 1.25rem 0.5rem; background:var(--card2);">
1135
+ <div class="tabs-wrap">
1127
1136
  <div class="tabs">
1128
1137
  <button type="button" id="tab-button-general" class="tab-btn active" onclick="switchTab('tab-general')">General</button>
1129
1138
  <button type="button" id="tab-button-routing" class="tab-btn" onclick="switchTab('tab-routing')">Routing & Protocol</button>
@@ -1160,7 +1169,7 @@
1160
1169
  </label>
1161
1170
 
1162
1171
  <label class="f">API Key
1163
- <input type="password" id="p-key-val" placeholder="sk-...">
1172
+ <input type="password" id="p-key-val" placeholder="sk-..." autocomplete="off">
1164
1173
  </label>
1165
1174
  </div>
1166
1175
 
@@ -2777,15 +2786,22 @@
2777
2786
  return;
2778
2787
  }
2779
2788
  say('Installed v' + result.to + '. Restarting the gateway...');
2789
+ let last = 'no check ran';
2780
2790
  for (let n = 0; n < 60; n++) {
2781
2791
  await new Promise(r => setTimeout(r, 500));
2782
- const pid = await gatewayPid();
2783
- if (pid && pid !== before) {
2784
- location.reload();
2785
- return;
2792
+ try {
2793
+ const r = await fetch('/health?challenge=ui', { cache: 'no-store' });
2794
+ const pid = (await r.json()).pid ?? null;
2795
+ if (pid && pid !== before) {
2796
+ location.reload();
2797
+ return;
2798
+ }
2799
+ last = pid ? 'the gateway from before the update (pid ' + pid + ')' : 'HTTP ' + r.status + ' with no pid';
2800
+ } catch (err) {
2801
+ last = err.message;
2786
2802
  }
2787
2803
  }
2788
- throw new Error('The new gateway did not answer. Read proxy.log.');
2804
+ throw new Error('The new gateway did not answer (last check: ' + last + '). Read proxy.log.');
2789
2805
  } catch (err) {
2790
2806
  say(err.message);
2791
2807
  showToast('Update failed: ' + err.message, true);