@bakapiano/ccsm 0.19.4 → 0.20.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/lib/tunnel.js CHANGED
@@ -288,13 +288,39 @@ async function probe(force = false) {
288
288
  return probeCache;
289
289
  }
290
290
 
291
+ // Kick a single background probe refresh, deduped so overlapping callers
292
+ // share one shell-out. Never throws.
293
+ let probeRefreshing = null;
294
+ function kickProbeRefresh() {
295
+ if (!probeRefreshing) {
296
+ probeRefreshing = probe(true)
297
+ .catch(() => probeCache)
298
+ .finally(() => { probeRefreshing = null; });
299
+ }
300
+ return probeRefreshing;
301
+ }
302
+
291
303
  // Invalidate the cache when callers know the on-disk state likely changed
292
- // (post-install, post-login, etc.). Next probe() re-shells.
293
- function invalidateProbe() { probeCache = null; probeCacheAt = 0; }
304
+ // (post-install, post-login, etc.) and immediately start repopulating it
305
+ // in the background so the next status poll is already fresh.
306
+ function invalidateProbe() { probeCache = null; probeCacheAt = 0; kickProbeRefresh(); }
307
+
308
+ // Stale-while-revalidate accessor used by status(). NEVER shells out in
309
+ // the request path: returns whatever's cached right now (possibly stale,
310
+ // or null on the very first call before the boot prewarm lands) and kicks
311
+ // off a background refresh when the cache is stale. This is what keeps
312
+ // /api/tunnel/status — and therefore the whole Remote page's live refresh
313
+ // (plus the device list, which the client used to bundle into the same
314
+ // round-trip) — from stalling ~700ms every time the 30s cache expires.
315
+ function probeCachedSWR() {
316
+ const fresh = probeCache && Date.now() - probeCacheAt < PROBE_TTL_MS;
317
+ if (!fresh) kickProbeRefresh();
318
+ return probeCache;
319
+ }
294
320
 
295
321
  async function status() {
296
322
  return {
297
- providers: await probe(),
323
+ providers: probeCachedSWR(),
298
324
  running: !!current,
299
325
  provider: current?.provider || null,
300
326
  url: current?.url || null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakapiano/ccsm",
3
- "version": "0.19.4",
3
+ "version": "0.20.1",
4
4
  "description": "Claude Code Session Manager — Windows web UI to manage many concurrent claude sessions: live list, snapshot/restore, focus existing window, new session in an isolated workspace with repo clones",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
@@ -0,0 +1,124 @@
1
+ /* Dark-mode overrides.
2
+ *
3
+ * The bulk of the UI flips automatically: surfaces, ink, and borders all
4
+ * read from CSS vars that state.js (and the pre-paint script in index.html)
5
+ * re-derive for the dark ground. This file only patches the stragglers —
6
+ * places that hardcoded a light-assuming literal color instead of a var,
7
+ * plus a few values that need a genuinely different treatment on dark
8
+ * (focus rings, primary-button hover, status text contrast, scrims).
9
+ *
10
+ * Loaded LAST so these win the cascade. Everything is scoped under
11
+ * [data-theme="dark"] on <html>, set by applyTheme().
12
+ *
13
+ * NOT touched here (intentionally dark already): the terminal pane, the
14
+ * session tabs, and the mobile key bar — those are dark in both themes. */
15
+
16
+ /* ── buttons ─────────────────────────────────────────────────────── */
17
+ /* .action.primary is bg:var(--ink)/text:var(--bg-elev) — already inverts
18
+ correctly (light slab, dark text) when the vars flip. Only its hover
19
+ hardcoded #000, which would darken the wrong way; send it brighter. */
20
+ [data-theme="dark"] .action.primary:hover {
21
+ background: #ffffff;
22
+ border-color: #ffffff;
23
+ box-shadow: 0 4px 14px -4px rgba(0, 0, 0, 0.6);
24
+ }
25
+ /* .fab base is var(--ink) (a light slab in dark mode) with var(--bg-elev)
26
+ text; its hover hardcoded #000, which would render dark text on black.
27
+ Send the hover lighter instead, matching .action.primary. */
28
+ [data-theme="dark"] .fab:hover { background: #ffffff; }
29
+ /* Focus rings / hover shadows used a dark ink wash that vanishes on a dark
30
+ ground — switch to a light wash so the affordance stays visible. */
31
+ [data-theme="dark"] .action:hover { box-shadow: 0 2px 4px -2px rgba(0, 0, 0, 0.5); }
32
+ [data-theme="dark"] .action:focus-visible { box-shadow: 0 0 0 3px rgba(236, 231, 218, 0.16); }
33
+ [data-theme="dark"] .input:focus,
34
+ [data-theme="dark"] input:focus,
35
+ [data-theme="dark"] select:focus,
36
+ [data-theme="dark"] textarea:focus { box-shadow: 0 0 0 3px rgba(236, 231, 218, 0.12); }
37
+ [data-theme="dark"] .action.danger:hover { background: #c75050; border-color: #c75050; }
38
+
39
+ /* The select chevron SVG is baked with a mid-gray stroke; lighten it. */
40
+ [data-theme="dark"] select {
41
+ background-image: url("data:image/svg+xml;utf8,<svg viewBox='0 0 12 8' xmlns='http://www.w3.org/2000/svg' fill='none' stroke='%23b4ab98' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='1,1 6,7 11,1'/></svg>");
42
+ }
43
+
44
+ /* ── brand mark ──────────────────────────────────────────────────── */
45
+ /* The logo's "terminal window" rect is near-black (#1a1815) and vanishes
46
+ against the dark page. Lift its fill so it reads as a small elevated
47
+ panel — no hard outline (a border looks boxy at this size); the lighter
48
+ fill alone separates it. (Light mode leaves the presentation-attribute
49
+ fill untouched — it's already legible there.) */
50
+ [data-theme="dark"] .brand-rect { fill: #38342f; }
51
+
52
+ /* ── paper grain ─────────────────────────────────────────────────── */
53
+ /* The noise texture is a dark-tinted SVG multiplied over the surface —
54
+ invisible (and wrong blend) on a dark ground. Screen-blend it at low
55
+ opacity so it adds faint light speckle instead. */
56
+ [data-theme="dark"] body::before { mix-blend-mode: screen; opacity: 0.4; }
57
+
58
+ /* ── Microsoft sign-in button ────────────────────────────────────── */
59
+ /* Microsoft's brand guidance ships a dark-theme variant of the sign-in
60
+ button (dark fill, light text). The four-square logo stays as-is. */
61
+ [data-theme="dark"] .btn-signin-microsoft {
62
+ background: #2b2b2b;
63
+ border-color: #5e5e5e;
64
+ color: #ffffff;
65
+ }
66
+ [data-theme="dark"] .btn-signin-microsoft:hover { background: #383838; border-color: #6f6f6f; }
67
+ [data-theme="dark"] .btn-signin-microsoft:active { background: #1f1f1f; }
68
+
69
+ /* ── status / semantic text contrast ─────────────────────────────── */
70
+ /* These literals were tuned for dark-text-on-light. On a dark ground they
71
+ read as near-black mud — lift each to a legible tint of the same hue. */
72
+ [data-theme="dark"] .provider-status-state.is-ok,
73
+ [data-theme="dark"] .status-link.ok,
74
+ [data-theme="dark"] .status-label.ok { color: #7fc77f; }
75
+ [data-theme="dark"] .provider-status-state.is-warn,
76
+ [data-theme="dark"] .status-label.warn,
77
+ [data-theme="dark"] .signin-error-msg { color: #e89090; }
78
+ [data-theme="dark"] .signin-error-msg.is-active,
79
+ [data-theme="dark"] .tunnel-stop-link:hover { color: #f0a8a8; }
80
+ [data-theme="dark"] .status-label.blue { color: #6fb0e8; }
81
+ [data-theme="dark"] .remote-status-line .warn,
82
+ [data-theme="dark"] .warning-bg { color: #d9a066; }
83
+ [data-theme="dark"] .warning-tag { border-color: #b87a3a; background: rgba(217, 160, 102, 0.08); }
84
+ [data-theme="dark"] .warning-bg { background: rgba(217, 160, 102, 0.16); }
85
+
86
+ /* Light result panels (success/error) → translucent tints on dark. */
87
+ [data-theme="dark"] .signin-card-result.is-ok { background: rgba(74, 138, 74, 0.14); }
88
+ [data-theme="dark"] .signin-card-result.is-error { background: rgba(183, 63, 63, 0.16); }
89
+
90
+ /* Scrims sit over content — a touch deeper reads better against dark UI. */
91
+ [data-theme="dark"] .modal-backdrop,
92
+ [data-theme="dark"] .offline-overlay,
93
+ [data-theme="dark"] .kbd-recorder-overlay { background: rgba(0, 0, 0, 0.6); }
94
+
95
+ /* ── terminal chrome ─────────────────────────────────────────────── */
96
+ /* Dark values for the --term-* palette (terminals.css holds the light
97
+ defaults). These restore the original near-black terminal surround,
98
+ tab strip, empty/displaced states, and mobile key bar. Keep in lockstep
99
+ with TerminalView.js's THEME_DARK. */
100
+ [data-theme="dark"] {
101
+ /* VSCode Dark+ neutral panel grays around the #1e1e1e terminal canvas. */
102
+ --term-surface: #1e1e1e; /* matches THEME_DARK.background */
103
+ --term-on: #cccccc;
104
+ --term-on-dim: rgba(204, 204, 204, 0.72);
105
+ --term-on-faint: rgba(204, 204, 204, 0.45);
106
+ --term-heading: #ffffff;
107
+ --term-prompt: #f14c4c; /* VSCode ansiBrightRed */
108
+ --term-cta-bg: #cccccc;
109
+ --term-cta-fg: #1e1e1e;
110
+ --term-cta-bg-hover: #e5e5e5;
111
+ --term-tabstrip: #252526;
112
+ --term-tab: #2d2d2d;
113
+ --term-tab-hover: #333333;
114
+ --term-tab-text: rgba(204, 204, 204, 0.65);
115
+ --term-keybar-bg: #252526;
116
+ --term-key-fg: #cccccc;
117
+ --term-key-bg: rgba(255, 255, 255, 0.06);
118
+ --term-key-border: rgba(255, 255, 255, 0.14);
119
+ --term-key-active-bg: rgba(255, 255, 255, 0.20);
120
+ --term-key-active-border: rgba(255, 255, 255, 0.32);
121
+ --term-key-hint: rgba(204, 204, 204, 0.5);
122
+ --term-pop-bg: #252526;
123
+ --term-pop-border: rgba(255, 255, 255, 0.16);
124
+ }
@@ -202,39 +202,50 @@
202
202
  text-align: center;
203
203
  }
204
204
 
205
- /* RestartOverlay — small top-of-viewport pill (not fullscreen) while
206
- a user-initiated backend restart is in flight. Slides down from the
207
- top, slides out on dismiss. Pinned center so it survives PWA WCO /
208
- standalone window-control overlays without bumping into them. */
205
+ /* RestartOverlay — a transient pill shown while a user-initiated backend
206
+ restart is in flight. Styled as a toast and pinned to the same
207
+ bottom-right slot so every floating notification in the app lives in
208
+ one place and reads the same. (It only ever coexists with a real toast
209
+ on restart *failure*, by which point the banner has already unmounted,
210
+ so sharing the slot doesn't collide.) Colors track the theme via
211
+ var(--ink)/var(--bg) — the old hardcoded #fff text went invisible on
212
+ the light pill dark mode produces. */
209
213
  .restart-banner {
210
214
  position: fixed;
211
- top: env(titlebar-area-height, 16px);
212
- left: 50%;
213
- transform: translateX(-50%);
215
+ bottom: var(--s-5);
216
+ right: var(--s-5);
214
217
  z-index: 1200;
215
- display: inline-flex;
218
+ display: flex;
216
219
  align-items: center;
217
- gap: 10px;
218
- padding: 8px 16px;
219
- border-radius: 999px;
220
+ gap: 12px;
221
+ max-width: 380px;
222
+ padding: 11px 16px 11px 14px;
220
223
  background: var(--ink);
221
- color: #fff;
224
+ color: var(--bg);
225
+ border-radius: 6px;
222
226
  font-size: 12.5px;
223
- font-weight: 500;
227
+ font-weight: 400;
224
228
  letter-spacing: -0.005em;
225
- box-shadow: 0 4px 18px rgba(0, 0, 0, 0.18);
226
- animation: restart-banner-in .18s ease-out;
229
+ line-height: 1.45;
230
+ box-shadow:
231
+ 0 10px 32px -8px rgba(26, 24, 21, 0.30),
232
+ 0 2px 6px rgba(26, 24, 21, 0.12),
233
+ inset 0 0 0 1px rgba(255, 255, 255, 0.06);
234
+ animation: toast-pop-in .28s cubic-bezier(.34, 1.4, .64, 1);
227
235
  }
228
- @keyframes restart-banner-in {
229
- from { opacity: 0; transform: translate(-50%, -8px); }
230
- to { opacity: 1; transform: translate(-50%, 0); }
236
+ @keyframes toast-pop-in {
237
+ from { opacity: 0; transform: translateY(10px) scale(0.98); }
238
+ to { opacity: 1; transform: translateY(0) scale(1); }
231
239
  }
232
240
  .restart-banner-spinner {
233
- width: 12px;
234
- height: 12px;
241
+ width: 13px;
242
+ height: 13px;
243
+ flex-shrink: 0;
235
244
  border-radius: 50%;
236
- border: 2px solid rgba(255, 255, 255, 0.25);
237
- border-top-color: #fff;
245
+ /* currentColor = the pill's text color (var(--bg)), so the spinner
246
+ stays legible in both themes without a hardcoded white. */
247
+ border: 2px solid color-mix(in srgb, currentColor 28%, transparent);
248
+ border-top-color: currentColor;
238
249
  animation: restart-spin 0.7s linear infinite;
239
250
  }
240
251
  .restart-banner-text { white-space: nowrap; }
@@ -312,8 +323,11 @@
312
323
  font-weight: 500;
313
324
  padding: 3px 6px;
314
325
  border-radius: 4px;
315
- background: rgba(255, 255, 255, 0.10);
316
- color: rgba(255, 255, 255, 0.75);
326
+ /* currentColor = the pill's text (var(--bg)), so the default chip stays
327
+ visible whether the pill is dark (light theme) or light (dark theme).
328
+ The .ok / .error variants below override with their own colored bg. */
329
+ background: color-mix(in srgb, currentColor 14%, transparent);
330
+ color: currentColor;
317
331
  flex-shrink: 0;
318
332
  line-height: 1;
319
333
  }
@@ -63,7 +63,11 @@
63
63
  .action.danger {
64
64
  background: var(--red);
65
65
  border-color: var(--red);
66
- color: var(--bg-elev);
66
+ /* Always light text — the danger red is dark in both themes, so it must
67
+ NOT follow --bg-elev (which is a dark surface in dark mode and would
68
+ render black text on red). In light mode --bg-elev was #fff anyway,
69
+ so this is identical there. */
70
+ color: #fff;
67
71
  }
68
72
  .action.danger:hover {
69
73
  background: #9a3636;
@@ -103,6 +107,40 @@ textarea {
103
107
  line-height: 1.55;
104
108
  }
105
109
 
110
+ /* Segmented control — a row of mutually-exclusive pills sharing one
111
+ border (used for the Appearance light/dark/system toggle). */
112
+ .seg {
113
+ display: inline-flex;
114
+ padding: 2px;
115
+ gap: 2px;
116
+ background: var(--ui-bg);
117
+ border: 1px solid var(--border-strong);
118
+ border-radius: 8px;
119
+ }
120
+ .seg-btn {
121
+ appearance: none;
122
+ background: transparent;
123
+ border: 0;
124
+ color: var(--ink-mid);
125
+ font-family: var(--body);
126
+ font-size: 12.5px;
127
+ font-weight: 500;
128
+ padding: 5px 13px;
129
+ border-radius: 6px;
130
+ cursor: pointer;
131
+ transition: background .14s ease, color .14s ease;
132
+ display: inline-flex;
133
+ align-items: center;
134
+ gap: 6px;
135
+ }
136
+ .seg-btn svg { width: 14px; height: 14px; opacity: 0.85; }
137
+ .seg-btn:hover { color: var(--ink); }
138
+ .seg-btn.is-active {
139
+ background: var(--bg-elev);
140
+ color: var(--ink);
141
+ box-shadow: var(--shadow-sm);
142
+ }
143
+
106
144
  input[type="checkbox"] {
107
145
  appearance: none;
108
146
  width: 16px;
@@ -125,11 +125,10 @@ body.is-resizing-sidebar .app {
125
125
  box-sizing: border-box;
126
126
  margin: 0 calc(-1 * var(--s-4)) 0;
127
127
  padding: 0 var(--s-5);
128
- /* Replace the hard inset shadow rule with a soft gradient fade gives
129
- the top band a printed-paper masthead feeling rather than a CSS
130
- divider. */
131
- background:
132
- linear-gradient(to bottom, rgba(216, 212, 198, 0.0) 0%, rgba(216, 212, 198, 0.0) calc(100% - 1px), var(--ui-border-soft) 100%);
128
+ /* Bottom divider matches the sidebar's right border exactlysame
129
+ 1px solid var(--ui-border) so the header underline and the
130
+ sidebar/main divider read as one continuous frame. */
131
+ border-bottom: 1px solid var(--ui-border);
133
132
  color: var(--ink);
134
133
  font-size: 13px;
135
134
  font-weight: 400;
@@ -1,5 +1,38 @@
1
1
  /* Terminals tab · left rail (active sessions) + right pane (xterm host) */
2
2
 
3
+ /* Terminal-chrome palette. The xterm canvas itself is painted from a JS
4
+ theme object (TerminalView.js); these vars colour everything AROUND it —
5
+ the pane backdrop, the tab strip, the empty/displaced states, and the
6
+ mobile key bar — so the chrome tracks the canvas. Light defaults here;
7
+ dark.css overrides under [data-theme="dark"] with the original dark set.
8
+ Keep the two in lockstep with the JS THEME_LIGHT / THEME_DARK objects. */
9
+ :root {
10
+ /* VSCode Light+ neutral panel grays, to sit seamlessly around the
11
+ white VSCode terminal canvas (THEME_LIGHT). */
12
+ --term-surface: #ffffff; /* matches THEME_LIGHT.background */
13
+ --term-on: #333333;
14
+ --term-on-dim: rgba(51, 51, 51, 0.70);
15
+ --term-on-faint: rgba(51, 51, 51, 0.45);
16
+ --term-heading: #1a1a1a;
17
+ --term-prompt: #cd3131; /* VSCode ansiRed */
18
+ --term-cta-bg: #2c2c2c;
19
+ --term-cta-fg: #ffffff;
20
+ --term-cta-bg-hover: #000000;
21
+ --term-tabstrip: #f0f0f0;
22
+ --term-tab: #e4e4e4;
23
+ --term-tab-hover: #d8d8d8;
24
+ --term-tab-text: rgba(51, 51, 51, 0.70);
25
+ --term-keybar-bg: #f0f0f0;
26
+ --term-key-fg: #333333;
27
+ --term-key-bg: rgba(0, 0, 0, 0.05);
28
+ --term-key-border: rgba(0, 0, 0, 0.14);
29
+ --term-key-active-bg: rgba(0, 0, 0, 0.12);
30
+ --term-key-active-border: rgba(0, 0, 0, 0.28);
31
+ --term-key-hint: rgba(0, 0, 0, 0.5);
32
+ --term-pop-bg: #f6f6f6;
33
+ --term-pop-border: rgba(0, 0, 0, 0.16);
34
+ }
35
+
3
36
  .terminals-layout {
4
37
  display: grid;
5
38
  grid-template-columns: 240px 1fr;
@@ -246,7 +279,7 @@
246
279
  terminal. Negative horizontal margin cancels .main's padding so
247
280
  the strip is full-bleed like the terminal underneath. */
248
281
  margin: 0 calc(-1 * var(--s-4)) calc(-1 * var(--s-4));
249
- background: #2d2a26;
282
+ background: var(--term-tabstrip);
250
283
  border-bottom: 0;
251
284
  }
252
285
  .session-tabs-list {
@@ -272,7 +305,7 @@
272
305
  }
273
306
  .session-tab {
274
307
  appearance: none;
275
- background: #423d37;
308
+ background: var(--term-tab);
276
309
  border: 0;
277
310
  border-bottom: 2px solid transparent;
278
311
  margin-bottom: -1px; /* overlap container border-bottom */
@@ -282,17 +315,17 @@
282
315
  gap: 6px;
283
316
  font: inherit;
284
317
  font-size: 12px;
285
- color: rgba(255, 255, 255, 0.65);
318
+ color: var(--term-tab-text);
286
319
  cursor: pointer;
287
320
  max-width: 200px;
288
321
  min-width: 0;
289
322
  transition: background-color .12s, color .12s;
290
323
  }
291
- .session-tab:hover { background: #4f4942; color: #fff; }
324
+ .session-tab:hover { background: var(--term-tab-hover); color: var(--term-on); }
292
325
  .session-tab.is-active {
293
- background: var(--ink);
294
- color: #fff;
295
- border-bottom-color: var(--ink);
326
+ background: var(--term-surface);
327
+ color: var(--term-on);
328
+ border-bottom-color: var(--term-surface);
296
329
  }
297
330
  .session-tab-icon { display: inline-flex; flex-shrink: 0; }
298
331
  .session-tab-icon svg { width: 14px; height: 14px; }
@@ -303,7 +336,7 @@
303
336
  text-overflow: ellipsis;
304
337
  min-width: 0;
305
338
  }
306
- .session-tab-meta { color: rgba(255, 255, 255, 0.5); font-size: 11px; }
339
+ .session-tab-meta { color: var(--term-tab-text); font-size: 11px; }
307
340
  .session-tab.is-active .session-tab-meta { color: rgba(255, 255, 255, 0.6); }
308
341
  .session-tab-add {
309
342
  background: transparent;
@@ -412,14 +445,14 @@
412
445
  .session-pane-body {
413
446
  flex: 1;
414
447
  min-height: 0;
415
- background: #1a1815;
448
+ background: var(--term-surface);
416
449
  }
417
450
  .session-pane-body .terminal-host {
418
451
  height: 100%;
419
452
  }
420
453
  .session-pane-body .terminal-empty {
421
- background: #1a1815;
422
- color: #e8e3d5;
454
+ background: var(--term-surface);
455
+ color: var(--term-on);
423
456
  display: flex;
424
457
  flex-direction: column;
425
458
  align-items: center;
@@ -429,16 +462,16 @@
429
462
  font-size: 13px;
430
463
  }
431
464
  .session-pane-body .terminal-empty .mono {
432
- color: #e07b6e;
465
+ color: var(--term-prompt);
433
466
  }
434
467
  .session-pane-body .terminal-empty .action.primary {
435
- background: #e8e3d5;
436
- color: #1a1815;
437
- border-color: #e8e3d5;
468
+ background: var(--term-cta-bg);
469
+ color: var(--term-cta-fg);
470
+ border-color: var(--term-cta-bg);
438
471
  }
439
472
  .session-pane-body .terminal-empty .action.primary:hover {
440
- background: #faf9f5;
441
- border-color: #faf9f5;
473
+ background: var(--term-cta-bg-hover);
474
+ border-color: var(--term-cta-bg-hover);
442
475
  }
443
476
 
444
477
  /* Displaced state — shown when the server kicks us off because another
@@ -446,8 +479,8 @@
446
479
  as terminal-empty so the transition from running terminal → displaced
447
480
  doesn't flash a colour change. */
448
481
  .terminal-displaced {
449
- background: #1a1815;
450
- color: #e8e3d5;
482
+ background: var(--term-surface);
483
+ color: var(--term-on);
451
484
  display: flex;
452
485
  align-items: center;
453
486
  justify-content: center;
@@ -465,14 +498,14 @@
465
498
  margin: 0;
466
499
  font-size: 16px;
467
500
  font-weight: 600;
468
- color: #faf9f5;
501
+ color: var(--term-heading);
469
502
  letter-spacing: -0.005em;
470
503
  }
471
504
  .terminal-displaced-card p {
472
505
  margin: 0;
473
506
  font-size: 13px;
474
507
  line-height: 1.55;
475
- color: rgba(232, 227, 213, 0.72);
508
+ color: var(--term-on-dim);
476
509
  }
477
510
  .terminal-displaced-actions {
478
511
  margin-top: var(--s-2);
@@ -480,19 +513,19 @@
480
513
  justify-content: center;
481
514
  }
482
515
  .terminal-displaced-card .action.primary {
483
- background: #e8e3d5;
484
- color: #1a1815;
485
- border-color: #e8e3d5;
516
+ background: var(--term-cta-bg);
517
+ color: var(--term-cta-fg);
518
+ border-color: var(--term-cta-bg);
486
519
  padding: 9px 20px;
487
520
  font-size: 13px;
488
521
  }
489
522
  .terminal-displaced-card .action.primary:hover {
490
- background: #faf9f5;
491
- border-color: #faf9f5;
523
+ background: var(--term-cta-bg-hover);
524
+ border-color: var(--term-cta-bg-hover);
492
525
  }
493
526
  .terminal-displaced-hint {
494
527
  font-size: 11.5px !important;
495
- color: rgba(232, 227, 213, 0.45) !important;
528
+ color: var(--term-on-faint) !important;
496
529
  }
497
530
 
498
531
  /* ─── Mobile terminal accessory bar (TerminalKeyBar.js) ───────────────
@@ -505,8 +538,8 @@
505
538
  left: 0;
506
539
  right: 0;
507
540
  z-index: 215; /* above the mobile FAB (210) */
508
- background: #201d19;
509
- border-top: 1px solid rgba(232, 227, 213, 0.12);
541
+ background: var(--term-keybar-bg);
542
+ border-top: 1px solid var(--term-key-border);
510
543
  padding: 6px 8px;
511
544
  touch-action: manipulation; /* kill the 300ms double-tap-zoom delay */
512
545
  user-select: none;
@@ -538,17 +571,17 @@
538
571
  font-family: var(--mono);
539
572
  font-size: 13px;
540
573
  line-height: 1;
541
- color: #e8e3d5;
542
- background: rgba(232, 227, 213, 0.06);
543
- border: 1px solid rgba(232, 227, 213, 0.14);
574
+ color: var(--term-key-fg);
575
+ background: var(--term-key-bg);
576
+ border: 1px solid var(--term-key-border);
544
577
  border-radius: 8px;
545
578
  touch-action: manipulation;
546
579
  -webkit-tap-highlight-color: transparent;
547
580
  }
548
581
  .tkb-key:active,
549
582
  .tkb-key.is-active {
550
- background: rgba(232, 227, 213, 0.20);
551
- border-color: rgba(232, 227, 213, 0.32);
583
+ background: var(--term-key-active-bg);
584
+ border-color: var(--term-key-active-border);
552
585
  }
553
586
  .tkb-arrow { padding: 0 10px; }
554
587
  .tkb-arrow svg { width: 18px; height: 18px; }
@@ -570,8 +603,8 @@
570
603
  grid-template-columns: repeat(5, 1fr);
571
604
  gap: 6px;
572
605
  padding: 8px;
573
- background: #201d19;
574
- border: 1px solid rgba(232, 227, 213, 0.16);
606
+ background: var(--term-pop-bg);
607
+ border: 1px solid var(--term-pop-border);
575
608
  border-radius: 10px;
576
609
  box-shadow: 0 -8px 24px -8px rgba(0, 0, 0, 0.5);
577
610
  }
@@ -582,5 +615,5 @@
582
615
  padding: 7px 4px;
583
616
  gap: 2px;
584
617
  }
585
- .tkb-combo-label { font-family: var(--mono); font-size: 13px; color: #e8e3d5; }
586
- .tkb-combo-hint { font-size: 9.5px; color: rgba(232, 227, 213, 0.5); letter-spacing: 0.01em; }
618
+ .tkb-combo-label { font-family: var(--mono); font-size: 13px; color: var(--term-key-fg); }
619
+ .tkb-combo-hint { font-size: 9.5px; color: var(--term-key-hint); letter-spacing: 0.01em; }
@@ -1334,12 +1334,14 @@
1334
1334
  .adopt-tab.is-active {
1335
1335
  background: var(--ink);
1336
1336
  border-color: var(--ink);
1337
- color: #fff;
1337
+ /* var(--bg-elev), not #fff — the slab is var(--ink), which is LIGHT in
1338
+ dark mode, so white text would be invisible. --bg-elev tracks it. */
1339
+ color: var(--bg-elev);
1338
1340
  font-weight: 500;
1339
1341
  }
1340
1342
  .adopt-tab.is-active .adopt-tab-count {
1341
- background: rgba(255, 255, 255, 0.22);
1342
- color: #fff;
1343
+ background: color-mix(in srgb, var(--bg-elev) 22%, transparent);
1344
+ color: var(--bg-elev);
1343
1345
  }
1344
1346
  .adopt-tab-icon { display: inline-flex; width: 16px; height: 16px; }
1345
1347
  .adopt-tab-icon svg { width: 100%; height: 100%; }
@@ -2582,7 +2584,9 @@
2582
2584
  padding: 1px 7px;
2583
2585
  border-radius: 999px;
2584
2586
  background: var(--ink);
2585
- color: #fff;
2587
+ /* var(--bg-elev) tracks the --ink slab (light in dark mode) so the code
2588
+ stays legible; #fff would be white-on-light. */
2589
+ color: var(--bg-elev);
2586
2590
  font-variant-numeric: tabular-nums;
2587
2591
  }
2588
2592
  .remote-device-name { min-width: 0; }
@@ -1,6 +1,13 @@
1
1
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
2
+ <!-- The terminal-window body lifts on a dark browser/OS chrome so the
3
+ near-black mark doesn't vanish in the tab strip. Mirrors the in-app
4
+ BrandMark's [data-theme="dark"] .brand-rect treatment. -->
5
+ <style>
6
+ .body { fill: #1a1815; }
7
+ @media (prefers-color-scheme: dark) { .body { fill: #38342f; } }
8
+ </style>
2
9
  <!-- terminal window body -->
3
- <rect x="2" y="4" width="28" height="24" rx="3" fill="#1a1815"/>
10
+ <rect class="body" x="2" y="4" width="28" height="24" rx="3"/>
4
11
  <!-- title bar divider -->
5
12
  <line x1="2" y1="10" x2="30" y2="10" stroke="#faf9f5" stroke-width="0.6" opacity="0.45"/>
6
13
  <!-- traffic-light dots -->
package/public/index.html CHANGED
@@ -27,37 +27,69 @@
27
27
  redirect to ../ on backend upgrade would fall out of scope and
28
28
  the OS would re-show an address bar. -->
29
29
  <link rel="manifest" href="../manifest.webmanifest" />
30
- <!-- Apply accent color BEFORE stylesheets/paint to avoid a flash
31
- of the default warm-cream tokens.css bg. Mirrors
32
- applyAccentCssVars() in state.js. Falls back to the Ocean
33
- default (#2f6fa3) when no accent is saved so first-time
34
- visitors also see the correct bg from the first frame. -->
30
+ <!-- Apply theme (accent + light/dark) BEFORE stylesheets/paint to
31
+ avoid a flash of the default light tokens.css bg. Mirrors
32
+ applyTheme()/applyAccentCssVars() in state.js keep the two in
33
+ sync. Resolves 'system' against the OS, sets data-theme so the
34
+ [data-theme="dark"] CSS overrides apply from the first frame, and
35
+ derives the accent-tinted palette for the chosen ground. -->
35
36
  <script>
36
37
  (function () {
37
38
  try {
38
39
  var hex = localStorage.getItem('ccsm.accent');
39
40
  if (!/^#[0-9a-fA-F]{6}$/.test(hex || '')) hex = '#2f6fa3';
41
+ var mode = localStorage.getItem('ccsm.theme');
42
+ if (mode !== 'light' && mode !== 'dark' && mode !== 'system') mode = 'system';
43
+ var dark = mode === 'dark' || (mode === 'system'
44
+ && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
45
+
40
46
  var n = parseInt(hex.slice(1), 16);
41
- var r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
47
+ var A = { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
42
48
  var toHex = function (v) { v = Math.max(0, Math.min(255, Math.round(v))); var s = v.toString(16); return s.length < 2 ? '0' + s : s; };
43
- var mix = function (t) { return '#' + toHex(r * t + 255 * (1 - t)) + toHex(g * t + 255 * (1 - t)) + toHex(b * t + 255 * (1 - t)); };
44
- var darken = function (a) { return '#' + toHex(r * (1 - a)) + toHex(g * (1 - a)) + toHex(b * (1 - a)); };
45
- var bg = mix(0.04);
49
+ var rgb = function (c) { return '#' + toHex(c.r) + toHex(c.g) + toHex(c.b); };
50
+ var lerp = function (c1, c2, t) { return { r: c1.r + (c2.r - c1.r) * t, g: c1.g + (c2.g - c1.g) * t, b: c1.b + (c2.b - c1.b) * t }; };
51
+ var WHITE = { r: 255, g: 255, b: 255 }, BLACK = { r: 0, g: 0, b: 0 };
52
+ var DARK_BASE = { r: 0x18, g: 0x16, b: 0x12 }, LIGHT_INK = { r: 0xec, g: 0xe7, b: 0xda };
53
+
46
54
  var root = document.documentElement.style;
47
- root.setProperty('--accent', hex);
48
- root.setProperty('--accent-deep', darken(0.2));
49
- root.setProperty('--accent-soft', 'rgba(' + r + ',' + g + ',' + b + ',0.10)');
50
- root.setProperty('--accent-softer', 'rgba(' + r + ',' + g + ',' + b + ',0.04)');
51
- root.setProperty('--bg', bg);
52
- root.setProperty('--sidebar-bg', bg);
53
- root.setProperty('--sidebar-hover', mix(0.10));
54
- root.setProperty('--sidebar-active', mix(0.15));
55
- root.setProperty('--border', mix(0.15));
56
- root.setProperty('--border-soft', mix(0.12));
57
- root.setProperty('--border-strong', mix(0.25));
58
- root.setProperty('--ui-bg', mix(0.10));
55
+ var set = function (o) { for (var k in o) root.setProperty(k, o[k]); };
56
+ var vars;
57
+ if (dark) {
58
+ var bg = lerp(DARK_BASE, A, 0.06);
59
+ var lift = function (t) { return rgb(lerp(bg, LIGHT_INK, t)); };
60
+ vars = {
61
+ '--accent': hex,
62
+ '--accent-deep': rgb(lerp(A, LIGHT_INK, 0.18)),
63
+ '--accent-soft': 'rgba(' + A.r + ',' + A.g + ',' + A.b + ',0.18)',
64
+ '--accent-softer': 'rgba(' + A.r + ',' + A.g + ',' + A.b + ',0.07)',
65
+ '--bg': rgb(bg), '--bg-elev': lift(0.05), '--sidebar-bg': rgb(bg),
66
+ '--sidebar-hover': lift(0.09), '--sidebar-active': lift(0.15),
67
+ '--border': lift(0.14), '--border-soft': lift(0.09), '--border-strong': lift(0.24),
68
+ '--ui-bg': lift(0.05), '--ui-border': lift(0.16), '--ui-border-soft': lift(0.10),
69
+ '--ink': rgb(LIGHT_INK),
70
+ '--ink-mid': rgb(lerp(LIGHT_INK, DARK_BASE, 0.28)),
71
+ '--ink-muted': rgb(lerp(LIGHT_INK, DARK_BASE, 0.45)),
72
+ '--ink-faint': rgb(lerp(LIGHT_INK, DARK_BASE, 0.60)),
73
+ };
74
+ } else {
75
+ var mix = function (t) { return rgb(lerp(WHITE, A, t)); };
76
+ vars = {
77
+ '--accent': hex,
78
+ '--accent-deep': rgb(lerp(A, BLACK, 0.2)),
79
+ '--accent-soft': 'rgba(' + A.r + ',' + A.g + ',' + A.b + ',0.10)',
80
+ '--accent-softer': 'rgba(' + A.r + ',' + A.g + ',' + A.b + ',0.04)',
81
+ '--bg': mix(0.04), '--bg-elev': '#ffffff', '--sidebar-bg': mix(0.04),
82
+ '--sidebar-hover': mix(0.10), '--sidebar-active': mix(0.15),
83
+ '--border': mix(0.15), '--border-soft': mix(0.12), '--border-strong': mix(0.25),
84
+ '--ui-bg': mix(0.10), '--ui-border': '#d8d4c6', '--ui-border-soft': '#e6e2d4',
85
+ '--ink': '#1a1815', '--ink-mid': '#534e44', '--ink-muted': '#8a8475', '--ink-faint': '#b5af9d',
86
+ };
87
+ }
88
+ document.documentElement.dataset.theme = dark ? 'dark' : 'light';
89
+ document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
90
+ set(vars);
59
91
  var meta = document.querySelector('meta[name="theme-color"]');
60
- if (meta) meta.setAttribute('content', bg);
92
+ if (meta) meta.setAttribute('content', vars['--bg']);
61
93
  } catch (_) {}
62
94
  })();
63
95
  </script>
@@ -80,6 +112,8 @@
80
112
  <link rel="stylesheet" href="./css/terminals.css" />
81
113
  <link rel="stylesheet" href="./css/wco.css" />
82
114
  <link rel="stylesheet" href="./css/responsive.css" />
115
+ <!-- Loaded last so its [data-theme="dark"] rules win the cascade. -->
116
+ <link rel="stylesheet" href="./css/dark.css" />
83
117
 
84
118
  <script type="importmap">
85
119
  {
@@ -11,29 +11,50 @@ import { WebLinksAddon } from '@xterm/addon-web-links';
11
11
  import { ClipboardAddon } from '@xterm/addon-clipboard';
12
12
  import { WebglAddon } from '@xterm/addon-webgl';
13
13
  import { wsBase, getToken, getDeviceId } from '../backend.js';
14
+ import { isDarkTheme, themeMode } from '../state.js';
14
15
  import { TerminalKeyBar } from './TerminalKeyBar.js';
15
16
 
16
- // Dark xterm theme. We give the terminal a near-black ink background to
17
- // match what claude code's TUI assumes (it paints its own input box +
18
- // prompt with hardcoded dark backgrounds a light terminal makes those
19
- // regions look like black blocks). Cursor uses the favorite-star gold so
20
- // it pops against the ink without dragging brand orange back in.
21
- const THEME = {
22
- background: '#1a1815',
23
- foreground: '#e8e3d5',
24
- cursor: '#e3b341',
25
- cursorAccent: '#1a1815',
26
- selectionBackground: '#3a3530',
27
- black: '#1a1815', brightBlack: '#534e44',
28
- red: '#e07b6e', brightRed: '#f0a098',
29
- green: '#7fb670', brightGreen: '#a0d28f',
30
- yellow: '#e3b341', brightYellow: '#f0c860',
31
- blue: '#7d9fc4', brightBlue: '#9bb8d8',
32
- magenta: '#c08fd0', brightMagenta: '#d8aae2',
33
- cyan: '#6fb0b0', brightCyan: '#90c8c8',
34
- white: '#e8e3d5', brightWhite: '#faf9f5',
17
+ // Dark xterm theme VSCode's Dark+ terminal palette, verbatim (see
18
+ // microsoft/vscode src/.../terminal/common/terminalColorRegistry.ts).
19
+ // #1e1e1e ground, #ccc ink, the standard saturated ANSI set.
20
+ const THEME_DARK = {
21
+ background: '#1e1e1e',
22
+ foreground: '#cccccc',
23
+ cursor: '#aeafad',
24
+ cursorAccent: '#1e1e1e',
25
+ selectionBackground: '#264f78',
26
+ black: '#000000', brightBlack: '#666666',
27
+ red: '#cd3131', brightRed: '#f14c4c',
28
+ green: '#0dbc79', brightGreen: '#23d18b',
29
+ yellow: '#e5e510', brightYellow: '#f5f543',
30
+ blue: '#2472c8', brightBlue: '#3b8eea',
31
+ magenta: '#bc3fbc', brightMagenta: '#d670d6',
32
+ cyan: '#11a8cd', brightCyan: '#29b8db',
33
+ white: '#e5e5e5', brightWhite: '#e5e5e5',
35
34
  };
36
35
 
36
+ // Light xterm theme — VSCode's Light+ terminal palette, verbatim (see
37
+ // microsoft/vscode src/.../terminal/common/terminalColorRegistry.ts). Pure
38
+ // white ground, #333 ink, the classic saturated ANSI set tuned for legible
39
+ // contrast on white. The surrounding chrome (terminals.css --term-* light
40
+ // defaults) follows the same neutral light grays so it reads as one panel.
41
+ const THEME_LIGHT = {
42
+ background: '#ffffff',
43
+ foreground: '#333333',
44
+ cursor: '#000000',
45
+ cursorAccent: '#ffffff',
46
+ selectionBackground: '#add6ff',
47
+ black: '#000000', brightBlack: '#666666',
48
+ red: '#cd3131', brightRed: '#cd3131',
49
+ green: '#107c10', brightGreen: '#14ce14',
50
+ yellow: '#949800', brightYellow: '#b5ba00',
51
+ blue: '#0451a5', brightBlue: '#0451a5',
52
+ magenta: '#bc05bc', brightMagenta: '#bc05bc',
53
+ cyan: '#0598bc', brightCyan: '#0598bc',
54
+ white: '#555555', brightWhite: '#a5a5a5',
55
+ };
56
+ const themeFor = (dark) => (dark ? THEME_DARK : THEME_LIGHT);
57
+
37
58
  export function TerminalView({ terminalId, cliType }) {
38
59
  const hostRef = useRef(null);
39
60
  const termRef = useRef(null);
@@ -45,6 +66,11 @@ export function TerminalView({ terminalId, cliType }) {
45
66
  // currently holds the session.
46
67
  const [displaced, setDisplaced] = useState(false);
47
68
  const [reattachNonce, setReattach] = useState(0);
69
+ // Subscribe to the theme signal so a Settings toggle re-renders us and
70
+ // the theme-sync effect below re-runs. Holds the xterm theme currently
71
+ // applied so the IME handlers can re-issue it with a transparent cursor.
72
+ const mode = themeMode.value;
73
+ const themeRef = useRef(themeFor(isDarkTheme()));
48
74
 
49
75
  // Raw escape-sequence injector for the mobile key bar. Reads wsRef at
50
76
  // call time so it stays valid across reattaches without re-binding.
@@ -53,6 +79,24 @@ export function TerminalView({ terminalId, cliType }) {
53
79
  if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'input', data }));
54
80
  };
55
81
 
82
+ // Swap the xterm canvas palette when the resolved theme flips — both on
83
+ // an explicit Settings toggle (mode dep) and on an OS change while in
84
+ // 'system' mode (matchMedia listener). No remount: xterm re-rasterizes
85
+ // its glyph atlas from the new options.theme in place.
86
+ useEffect(() => {
87
+ const apply = () => {
88
+ const term = termRef.current;
89
+ if (!term) return;
90
+ const theme = themeFor(isDarkTheme());
91
+ themeRef.current = theme;
92
+ try { term.options.theme = theme; } catch {}
93
+ };
94
+ apply();
95
+ const mq = window.matchMedia('(prefers-color-scheme: dark)');
96
+ mq.addEventListener('change', apply);
97
+ return () => mq.removeEventListener('change', apply);
98
+ }, [mode, reattachNonce]);
99
+
56
100
  useEffect(() => {
57
101
  if (!terminalId || !hostRef.current) return;
58
102
 
@@ -63,6 +107,8 @@ export function TerminalView({ terminalId, cliType }) {
63
107
  // next mount (rare; users typically don't rotate mid-session).
64
108
  const isMobile = window.matchMedia('(max-width: 640px)').matches;
65
109
  const baseFontSize = isMobile ? 11 : 13;
110
+ const initialTheme = themeFor(isDarkTheme());
111
+ themeRef.current = initialTheme;
66
112
  const term = new Terminal({
67
113
  fontFamily: '"Cascadia Mono", "Geist Mono", "JetBrains Mono", Consolas, monospace',
68
114
  fontSize: baseFontSize,
@@ -71,7 +117,7 @@ export function TerminalView({ terminalId, cliType }) {
71
117
  cursorStyle: 'bar',
72
118
  scrollback: 5000,
73
119
  allowProposedApi: true,
74
- theme: THEME,
120
+ theme: initialTheme,
75
121
  // Modern keyboard protocols. Without these, xterm.js encodes
76
122
  // Shift+Enter, Ctrl+Enter, Ctrl+Shift+key etc. the same as their
77
123
  // unmodified versions (e.g. both Enter and Shift+Enter send \r),
@@ -381,16 +427,17 @@ export function TerminalView({ terminalId, cliType }) {
381
427
  // the CSS in terminals.css does the rest.
382
428
  const onCompStart = () => {
383
429
  if (host) host.classList.add('is-composing');
384
- // The terminal cursor is rendered on canvas (THEME.cursor), so CSS
430
+ // The terminal cursor is rendered on canvas (theme.cursor), so CSS
385
431
  // can't hide it. Theme swap alone doesn't reliably stop the blink
386
432
  // frame loop, so also issue the DECTCEM hide sequence which the
387
- // renderer honours immediately.
388
- try { term.options.theme = { ...THEME, cursor: 'transparent', cursorAccent: 'transparent' }; } catch {}
433
+ // renderer honours immediately. Use the live theme (themeRef) so the
434
+ // restore on compEnd matches whatever light/dark is current.
435
+ try { term.options.theme = { ...themeRef.current, cursor: 'transparent', cursorAccent: 'transparent' }; } catch {}
389
436
  try { term.write('\x1b[?25l'); } catch {}
390
437
  };
391
438
  const onCompEnd = () => {
392
439
  if (host) host.classList.remove('is-composing');
393
- try { term.options.theme = THEME; } catch {}
440
+ try { term.options.theme = themeRef.current; } catch {}
394
441
  try { term.write('\x1b[?25h'); } catch {}
395
442
  };
396
443
  const helper = host?.querySelector('.xterm-helper-textarea');
@@ -113,6 +113,15 @@ export const IconMonitor = ic('0 0 24 24', html`
113
113
  <line x1="12" y1="16" x2="12" y2="20"/>
114
114
  `, 13);
115
115
 
116
+ // Light / dark theme glyphs for the Appearance toggle.
117
+ export const IconSun = ic('0 0 24 24', html`
118
+ <circle cx="12" cy="12" r="4"/>
119
+ <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>
120
+ `, 14);
121
+ export const IconMoon = ic('0 0 24 24', html`
122
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
123
+ `, 14);
124
+
116
125
  // "> _" terminal prompt — for the Terminals nav tab
117
126
  export const IconTerminal = ic('0 0 24 24', html`
118
127
  <polyline points="4 17 10 11 4 5"/>
@@ -199,7 +208,7 @@ export const StarSmallFilled = ({ size = 14 } = {}) => html`
199
208
  // brand mark (terminal window + ccsm text — matches /favicon.svg)
200
209
  export const BrandMark = () => html`
201
210
  <svg viewBox="0 0 32 32" width="32" height="32">
202
- <rect x="2" y="4" width="28" height="24" rx="3" fill="#1a1815"/>
211
+ <rect class="brand-rect" x="2" y="4" width="28" height="24" rx="3" fill="#1a1815"/>
203
212
  <line x1="2" y1="10" x2="30" y2="10" stroke="#faf9f5" stroke-width="0.6" opacity="0.45"/>
204
213
  <!-- macOS traffic-light style: red / yellow / green -->
205
214
  <circle cx="6" cy="7" r="1" fill="#ed6a5e"/>
@@ -6,8 +6,8 @@ import { html } from '../html.js';
6
6
  import { useEffect, useState } from 'preact/hooks';
7
7
  import {
8
8
  config, configDirty, accentColor, folders, workspaces, serverHealth,
9
- restartInFlight,
10
- setAccentColor, ACCENT_DEFAULT,
9
+ restartInFlight, themeMode,
10
+ setAccentColor, ACCENT_DEFAULT, setThemeMode,
11
11
  } from '../state.js';
12
12
  import {
13
13
  api, loadConfig, loadWorkspaces, loadFolders,
@@ -24,7 +24,7 @@ import { Card } from '../components/Card.js';
24
24
  import { PageTitleBar } from '../components/PageTitleBar.js';
25
25
  import { EntityFormModal } from '../components/EntityFormModal.js';
26
26
  import { useDragSort } from '../components/useDragSort.js';
27
- import { IconPlus, IconPencil, IconClose, IconTerminal, IconFolder, IconBranch, IconRefresh, IconChevronUp, IconChevronDown, IconForCliType, IconClaudeColor, IconCodexColor, IconCopilotColor } from '../icons.js';
27
+ import { IconPlus, IconPencil, IconClose, IconTerminal, IconFolder, IconBranch, IconRefresh, IconChevronUp, IconChevronDown, IconForCliType, IconClaudeColor, IconCodexColor, IconCopilotColor, IconSun, IconMoon, IconMonitor } from '../icons.js';
28
28
  import { parseArgs, formatArgs } from '../util.js';
29
29
 
30
30
  // Tokenize the three free-form args fields into string[] before they hit
@@ -182,6 +182,10 @@ export function ConfigurePage() {
182
182
 
183
183
  <${Section} title="General">
184
184
  <div class="config-grid">
185
+ <div class="field">
186
+ <span class="label">Appearance</span>
187
+ <${ThemeToggle} />
188
+ </div>
185
189
  <div class="field">
186
190
  <span class="label">Theme accent</span>
187
191
  <${AccentPicker} />
@@ -577,6 +581,25 @@ function RestartButton() {
577
581
  `;
578
582
  }
579
583
 
584
+ function ThemeToggle() {
585
+ const mode = themeMode.value;
586
+ const opts = [
587
+ { id: 'light', label: 'Light', icon: IconSun },
588
+ { id: 'dark', label: 'Dark', icon: IconMoon },
589
+ { id: 'system', label: 'System', icon: IconMonitor },
590
+ ];
591
+ return html`
592
+ <div class="seg" role="group" aria-label="Appearance">
593
+ ${opts.map((o) => html`
594
+ <button key=${o.id} type="button"
595
+ class=${`seg-btn${mode === o.id ? ' is-active' : ''}`}
596
+ aria-pressed=${mode === o.id}
597
+ onClick=${() => setThemeMode(o.id)}>
598
+ <${o.icon} /><span>${o.label}</span>
599
+ </button>`)}
600
+ </div>`;
601
+ }
602
+
580
603
  function AccentPicker() {
581
604
  const current = (accentColor.value || '').toLowerCase();
582
605
  const matchedPreset = PRESETS.find((p) => p.hex.toLowerCase() === current);
@@ -311,14 +311,16 @@ export function RemotePage() {
311
311
  const [deviceList, setDeviceList] = useState([]);
312
312
  const pollRef = useRef(null);
313
313
 
314
- async function refresh() {
314
+ // Tunnel status and the device list are fetched INDEPENDENTLY, not as a
315
+ // bundled Promise.all. /api/tunnel/status can lag behind a cold provider
316
+ // probe; the device list is cheap. Coupling them made the (fast) device
317
+ // list wait on the (slow) status round-trip, so the whole page appeared
318
+ // to refresh in one delayed lump. Now each updates its own state the
319
+ // moment its own fetch lands.
320
+ async function refreshStatus() {
315
321
  try {
316
- const [s, devs] = await Promise.all([
317
- api('GET', '/api/tunnel/status'),
318
- api('GET', '/api/devices').catch(() => ({ devices: [] })),
319
- ]);
322
+ const s = await api('GET', '/api/tunnel/status');
320
323
  setStatus(s);
321
- setDeviceList(devs.devices || []);
322
324
  setTokenLocal((cur) => cur || s.token || '');
323
325
  setProvider((cur) => {
324
326
  if (s.running && s.provider) return s.provider;
@@ -336,6 +338,13 @@ export function RemotePage() {
336
338
  } catch {}
337
339
  } catch (e) { setToast(`status load failed · ${e.message}`, 'error'); }
338
340
  }
341
+ async function refreshDevices() {
342
+ try {
343
+ const devs = await api('GET', '/api/devices');
344
+ setDeviceList(devs.devices || []);
345
+ } catch { /* non-critical — keep the last good list on a transient error */ }
346
+ }
347
+ function refresh() { refreshStatus(); refreshDevices(); }
339
348
 
340
349
  useEffect(() => {
341
350
  refresh();
@@ -344,11 +353,11 @@ export function RemotePage() {
344
353
  }, []);
345
354
 
346
355
  async function onApproveDevice(id) {
347
- try { await api('POST', `/api/devices/${encodeURIComponent(id)}/approve`); refresh(); setToast('Device approved', 'ok'); }
356
+ try { await api('POST', `/api/devices/${encodeURIComponent(id)}/approve`); refreshDevices(); setToast('Device approved', 'ok'); }
348
357
  catch (e) { setToast(`approve failed · ${e.message}`, 'error'); }
349
358
  }
350
359
  async function onRejectDevice(id) {
351
- try { await api('POST', `/api/devices/${encodeURIComponent(id)}/reject`); refresh(); setToast('Device rejected', 'ok'); }
360
+ try { await api('POST', `/api/devices/${encodeURIComponent(id)}/reject`); refreshDevices(); setToast('Device rejected', 'ok'); }
352
361
  catch (e) { setToast(`reject failed · ${e.message}`, 'error'); }
353
362
  }
354
363
  async function onDeleteDevice(d) {
@@ -357,7 +366,7 @@ export function RemotePage() {
357
366
  { title: 'Delete device record', okLabel: 'Delete', danger: true },
358
367
  );
359
368
  if (!ok) return;
360
- try { await api('DELETE', `/api/devices/${encodeURIComponent(d.id)}`); refresh(); setToast('Device deleted', 'ok'); }
369
+ try { await api('DELETE', `/api/devices/${encodeURIComponent(d.id)}`); refreshDevices(); setToast('Device deleted', 'ok'); }
361
370
  catch (e) { setToast(`delete failed · ${e.message}`, 'error'); }
362
371
  }
363
372
  async function onRevokeDevice(d) {
@@ -365,13 +374,13 @@ export function RemotePage() {
365
374
  title: 'Revoke device', okLabel: 'Revoke', danger: true,
366
375
  });
367
376
  if (!ok) return;
368
- try { await api('POST', `/api/devices/${encodeURIComponent(d.id)}/revoke`); refresh(); setToast('Access revoked', 'ok'); }
377
+ try { await api('POST', `/api/devices/${encodeURIComponent(d.id)}/revoke`); refreshDevices(); setToast('Access revoked', 'ok'); }
369
378
  catch (e) { setToast(`revoke failed · ${e.message}`, 'error'); }
370
379
  }
371
380
  async function onRenameDevice(d) {
372
381
  const next = await ccsmPrompt('Rename device', d.label || '', { okLabel: 'Save' });
373
382
  if (next === null) return;
374
- try { await api('PUT', `/api/devices/${encodeURIComponent(d.id)}`, { label: next.trim() }); refresh(); }
383
+ try { await api('PUT', `/api/devices/${encodeURIComponent(d.id)}`, { label: next.trim() }); refreshDevices(); }
375
384
  catch (e) { setToast(`rename failed · ${e.message}`, 'error'); }
376
385
  }
377
386
 
@@ -39,6 +39,7 @@ export const isMobile = signal(false);
39
39
  export const mobileDrawerOpen = signal(false);
40
40
  export const sidebarWidth = signal(232); // px when expanded, persisted in localStorage
41
41
  export const accentColor = signal('#2f6fa3'); // user-chosen brand accent, persisted
42
+ export const themeMode = signal('system'); // 'light' | 'dark' | 'system', persisted
42
43
  // Per-folder collapse state in the sidebar tree. Stored as a plain object
43
44
  // {folderId: true} (true = collapsed). Key 'unsorted' covers the implicit
44
45
  // Unsorted bucket.
@@ -105,6 +106,7 @@ export const TAB_HEADINGS = {
105
106
  const LS_SIDEBAR = 'ccsm.sidebar-collapsed';
106
107
  const LS_SIDEBAR_W = 'ccsm.sidebar-width';
107
108
  const LS_ACCENT = 'ccsm.accent';
109
+ const LS_THEME = 'ccsm.theme';
108
110
  const LS_FOLDERS_COLLAPSED = 'ccsm.folders-collapsed';
109
111
  // Last-known sidebar tree, rehydrated on boot to keep the first paint
110
112
  // stable. The next refreshAll() overwrites these from the server, so
@@ -126,7 +128,9 @@ export function loadPersisted() {
126
128
  applySidebarWidthCssVar();
127
129
  const a = localStorage.getItem(LS_ACCENT);
128
130
  if (isHexColor(a)) accentColor.value = a;
129
- applyAccentCssVars();
131
+ const t = localStorage.getItem(LS_THEME);
132
+ if (t === 'light' || t === 'dark' || t === 'system') themeMode.value = t;
133
+ applyTheme();
130
134
  try {
131
135
  const raw = localStorage.getItem(LS_FOLDERS_COLLAPSED);
132
136
  if (raw) {
@@ -166,7 +170,7 @@ export function setSidebarWidth(px) {
166
170
  localStorage.setItem(LS_SIDEBAR_W, String(clamped));
167
171
  }
168
172
 
169
- // ── theme accent ────────────────────────────────────────────────
173
+ // ── theme (accent + light/dark) ─────────────────────────────────
170
174
  function isHexColor(s) {
171
175
  return typeof s === 'string' && /^#[0-9a-fA-F]{6}$/.test(s);
172
176
  }
@@ -178,48 +182,113 @@ function rgbToHex({ r, g, b }) {
178
182
  const h = (n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0');
179
183
  return `#${h(r)}${h(g)}${h(b)}`;
180
184
  }
181
- function darken({ r, g, b }, amount) {
182
- return { r: r * (1 - amount), g: g * (1 - amount), b: b * (1 - amount) };
185
+ // Linear blend c1→c2 by t∈[0,1]. t=0 yields c1, t=1 yields c2.
186
+ function lerp(c1, c2, t) {
187
+ return { r: c1.r + (c2.r - c1.r) * t, g: c1.g + (c2.g - c1.g) * t, b: c1.b + (c2.b - c1.b) * t };
183
188
  }
184
- function mixWithWhite({ r, g, b }, t) {
185
- return { r: r * t + 255 * (1 - t), g: g * t + 255 * (1 - t), b: b * t + 255 * (1 - t) };
189
+
190
+ // Anchor colors the palette is derived from. Light mode mixes the accent
191
+ // toward WHITE for surfaces and keeps warm-dark ink; dark mode mixes the
192
+ // accent toward DARK for surfaces and uses warm-light ink — same accent,
193
+ // inverted ground. Keep these in sync with the pre-paint script in
194
+ // public/index.html (it re-derives the same values to avoid a FOUC).
195
+ const WHITE = { r: 255, g: 255, b: 255 };
196
+ const DARK_BASE = { r: 0x18, g: 0x16, b: 0x12 }; // #181612 warm near-black
197
+ const LIGHT_INK = { r: 0xec, g: 0xe7, b: 0xda }; // #ece7da warm light text
198
+
199
+ // True when the effective theme is dark. 'system' consults the OS.
200
+ function resolveDark(mode) {
201
+ if (mode === 'dark') return true;
202
+ if (mode === 'light') return false;
203
+ return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
186
204
  }
205
+
187
206
  function applyAccentCssVars() {
188
207
  const base = accentColor.value;
189
- const rgb = hexToRgb(base);
190
- const deep = rgbToHex(darken(rgb, 0.2));
191
- const soft = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.10)`;
192
- const softer = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.04)`;
193
- const bg = rgbToHex(mixWithWhite(rgb, 0.04));
194
- const sidebarHover = rgbToHex(mixWithWhite(rgb, 0.10));
195
- const sidebarActive= rgbToHex(mixWithWhite(rgb, 0.15));
196
- const border = rgbToHex(mixWithWhite(rgb, 0.15));
197
- const borderSoft = rgbToHex(mixWithWhite(rgb, 0.12));
198
- const borderStrong = rgbToHex(mixWithWhite(rgb, 0.25));
199
- // UI chrome (sidebar bg, dividers, footer strip) — themed too but
200
- // visibly darker than the main bg so sidebar/main read as distinct.
201
- // Border colors stay deliberately desaturated so dividers don't shout
202
- // the brand color back at the user.
203
- const uiBg = rgbToHex(mixWithWhite(rgb, 0.10));
204
- const uiBorder = '#d8d4c6'; // theme-independent neutral
205
- const uiBorderSoft = '#e6e2d4'; // theme-independent neutral
208
+ const A = hexToRgb(base);
209
+ const dark = resolveDark(themeMode.value);
206
210
  const root = document.documentElement.style;
207
- root.setProperty('--accent', base);
208
- root.setProperty('--accent-deep', deep);
209
- root.setProperty('--accent-soft', soft);
210
- root.setProperty('--accent-softer', softer);
211
- root.setProperty('--bg', bg);
212
- root.setProperty('--sidebar-bg', bg);
213
- root.setProperty('--sidebar-hover', sidebarHover);
214
- root.setProperty('--sidebar-active', sidebarActive);
215
- root.setProperty('--border', border);
216
- root.setProperty('--border-soft', borderSoft);
217
- root.setProperty('--border-strong', borderStrong);
218
- root.setProperty('--ui-bg', uiBg);
219
- root.setProperty('--ui-border', uiBorder);
220
- root.setProperty('--ui-border-soft', uiBorderSoft);
211
+ let vars;
212
+ if (dark) {
213
+ const bg = lerp(DARK_BASE, A, 0.06); // dark ground, faint accent tint
214
+ const lift = (t) => rgbToHex(lerp(bg, LIGHT_INK, t)); // raise toward light
215
+ vars = {
216
+ '--accent': base,
217
+ '--accent-deep': rgbToHex(lerp(A, LIGHT_INK, 0.18)), // brighter on dark
218
+ '--accent-soft': `rgba(${A.r}, ${A.g}, ${A.b}, 0.18)`,
219
+ '--accent-softer': `rgba(${A.r}, ${A.g}, ${A.b}, 0.07)`,
220
+ '--bg': rgbToHex(bg),
221
+ '--bg-elev': lift(0.05),
222
+ '--sidebar-bg': rgbToHex(bg),
223
+ '--sidebar-hover': lift(0.09),
224
+ '--sidebar-active': lift(0.15),
225
+ '--border': lift(0.14),
226
+ '--border-soft': lift(0.09),
227
+ '--border-strong': lift(0.24),
228
+ '--ui-bg': lift(0.05),
229
+ '--ui-border': lift(0.16),
230
+ '--ui-border-soft': lift(0.10),
231
+ '--ink': rgbToHex(LIGHT_INK),
232
+ '--ink-mid': rgbToHex(lerp(LIGHT_INK, DARK_BASE, 0.28)),
233
+ '--ink-muted': rgbToHex(lerp(LIGHT_INK, DARK_BASE, 0.45)),
234
+ '--ink-faint': rgbToHex(lerp(LIGHT_INK, DARK_BASE, 0.60)),
235
+ };
236
+ } else {
237
+ const mix = (t) => rgbToHex(lerp(WHITE, A, t)); // light ground, accent tint
238
+ vars = {
239
+ '--accent': base,
240
+ '--accent-deep': rgbToHex(lerp(A, { r: 0, g: 0, b: 0 }, 0.2)),
241
+ '--accent-soft': `rgba(${A.r}, ${A.g}, ${A.b}, 0.10)`,
242
+ '--accent-softer': `rgba(${A.r}, ${A.g}, ${A.b}, 0.04)`,
243
+ '--bg': mix(0.04),
244
+ '--bg-elev': '#ffffff',
245
+ '--sidebar-bg': mix(0.04),
246
+ '--sidebar-hover': mix(0.10),
247
+ '--sidebar-active': mix(0.15),
248
+ '--border': mix(0.15),
249
+ '--border-soft': mix(0.12),
250
+ '--border-strong': mix(0.25),
251
+ '--ui-bg': mix(0.10),
252
+ '--ui-border': '#d8d4c6', // theme-independent neutral
253
+ '--ui-border-soft': '#e6e2d4', // theme-independent neutral
254
+ '--ink': '#1a1815',
255
+ '--ink-mid': '#534e44',
256
+ '--ink-muted': '#8a8475',
257
+ '--ink-faint': '#b5af9d',
258
+ };
259
+ }
260
+ for (const [k, v] of Object.entries(vars)) root.setProperty(k, v);
221
261
  const meta = document.querySelector('meta[name="theme-color"]');
222
- if (meta) meta.setAttribute('content', bg);
262
+ if (meta) meta.setAttribute('content', vars['--bg']);
263
+ }
264
+
265
+ // Set data-theme on <html> (drives the [data-theme="dark"] CSS overrides)
266
+ // and re-derive the accent-tinted palette for the resolved theme.
267
+ function applyTheme() {
268
+ const dark = resolveDark(themeMode.value);
269
+ document.documentElement.dataset.theme = dark ? 'dark' : 'light';
270
+ document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
271
+ applyAccentCssVars();
272
+ }
273
+
274
+ // React to OS theme changes while in 'system' mode.
275
+ if (window.matchMedia) {
276
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
277
+ if (themeMode.value === 'system') applyTheme();
278
+ });
279
+ }
280
+
281
+ // Resolved theme for non-CSS consumers (e.g. the xterm canvas, which is
282
+ // painted from a JS color object, not CSS vars).
283
+ export function isDarkTheme() {
284
+ return resolveDark(themeMode.value);
285
+ }
286
+
287
+ export function setThemeMode(mode) {
288
+ if (mode !== 'light' && mode !== 'dark' && mode !== 'system') return;
289
+ themeMode.value = mode;
290
+ applyTheme();
291
+ localStorage.setItem(LS_THEME, mode);
223
292
  }
224
293
 
225
294
  export function setAccentColor(hex) {