@bakapiano/ccsm 0.19.4 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakapiano/ccsm",
3
- "version": "0.19.4",
3
+ "version": "0.20.0",
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,120 @@
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
+ /* Focus rings / hover shadows used a dark ink wash that vanishes on a dark
26
+ ground — switch to a light wash so the affordance stays visible. */
27
+ [data-theme="dark"] .action:hover { box-shadow: 0 2px 4px -2px rgba(0, 0, 0, 0.5); }
28
+ [data-theme="dark"] .action:focus-visible { box-shadow: 0 0 0 3px rgba(236, 231, 218, 0.16); }
29
+ [data-theme="dark"] .input:focus,
30
+ [data-theme="dark"] input:focus,
31
+ [data-theme="dark"] select:focus,
32
+ [data-theme="dark"] textarea:focus { box-shadow: 0 0 0 3px rgba(236, 231, 218, 0.12); }
33
+ [data-theme="dark"] .action.danger:hover { background: #c75050; border-color: #c75050; }
34
+
35
+ /* The select chevron SVG is baked with a mid-gray stroke; lighten it. */
36
+ [data-theme="dark"] select {
37
+ 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>");
38
+ }
39
+
40
+ /* ── brand mark ──────────────────────────────────────────────────── */
41
+ /* The logo's "terminal window" rect is near-black (#1a1815) and vanishes
42
+ against the dark page. Lift its fill so it reads as a small elevated
43
+ panel — no hard outline (a border looks boxy at this size); the lighter
44
+ fill alone separates it. (Light mode leaves the presentation-attribute
45
+ fill untouched — it's already legible there.) */
46
+ [data-theme="dark"] .brand-rect { fill: #38342f; }
47
+
48
+ /* ── paper grain ─────────────────────────────────────────────────── */
49
+ /* The noise texture is a dark-tinted SVG multiplied over the surface —
50
+ invisible (and wrong blend) on a dark ground. Screen-blend it at low
51
+ opacity so it adds faint light speckle instead. */
52
+ [data-theme="dark"] body::before { mix-blend-mode: screen; opacity: 0.4; }
53
+
54
+ /* ── Microsoft sign-in button ────────────────────────────────────── */
55
+ /* Microsoft's brand guidance ships a dark-theme variant of the sign-in
56
+ button (dark fill, light text). The four-square logo stays as-is. */
57
+ [data-theme="dark"] .btn-signin-microsoft {
58
+ background: #2b2b2b;
59
+ border-color: #5e5e5e;
60
+ color: #ffffff;
61
+ }
62
+ [data-theme="dark"] .btn-signin-microsoft:hover { background: #383838; border-color: #6f6f6f; }
63
+ [data-theme="dark"] .btn-signin-microsoft:active { background: #1f1f1f; }
64
+
65
+ /* ── status / semantic text contrast ─────────────────────────────── */
66
+ /* These literals were tuned for dark-text-on-light. On a dark ground they
67
+ read as near-black mud — lift each to a legible tint of the same hue. */
68
+ [data-theme="dark"] .provider-status-state.is-ok,
69
+ [data-theme="dark"] .status-link.ok,
70
+ [data-theme="dark"] .status-label.ok { color: #7fc77f; }
71
+ [data-theme="dark"] .provider-status-state.is-warn,
72
+ [data-theme="dark"] .status-label.warn,
73
+ [data-theme="dark"] .signin-error-msg { color: #e89090; }
74
+ [data-theme="dark"] .signin-error-msg.is-active,
75
+ [data-theme="dark"] .tunnel-stop-link:hover { color: #f0a8a8; }
76
+ [data-theme="dark"] .status-label.blue { color: #6fb0e8; }
77
+ [data-theme="dark"] .remote-status-line .warn,
78
+ [data-theme="dark"] .warning-bg { color: #d9a066; }
79
+ [data-theme="dark"] .warning-tag { border-color: #b87a3a; background: rgba(217, 160, 102, 0.08); }
80
+ [data-theme="dark"] .warning-bg { background: rgba(217, 160, 102, 0.16); }
81
+
82
+ /* Light result panels (success/error) → translucent tints on dark. */
83
+ [data-theme="dark"] .signin-card-result.is-ok { background: rgba(74, 138, 74, 0.14); }
84
+ [data-theme="dark"] .signin-card-result.is-error { background: rgba(183, 63, 63, 0.16); }
85
+
86
+ /* Scrims sit over content — a touch deeper reads better against dark UI. */
87
+ [data-theme="dark"] .modal-backdrop,
88
+ [data-theme="dark"] .offline-overlay,
89
+ [data-theme="dark"] .kbd-recorder-overlay { background: rgba(0, 0, 0, 0.6); }
90
+
91
+ /* ── terminal chrome ─────────────────────────────────────────────── */
92
+ /* Dark values for the --term-* palette (terminals.css holds the light
93
+ defaults). These restore the original near-black terminal surround,
94
+ tab strip, empty/displaced states, and mobile key bar. Keep in lockstep
95
+ with TerminalView.js's THEME_DARK. */
96
+ [data-theme="dark"] {
97
+ /* VSCode Dark+ neutral panel grays around the #1e1e1e terminal canvas. */
98
+ --term-surface: #1e1e1e; /* matches THEME_DARK.background */
99
+ --term-on: #cccccc;
100
+ --term-on-dim: rgba(204, 204, 204, 0.72);
101
+ --term-on-faint: rgba(204, 204, 204, 0.45);
102
+ --term-heading: #ffffff;
103
+ --term-prompt: #f14c4c; /* VSCode ansiBrightRed */
104
+ --term-cta-bg: #cccccc;
105
+ --term-cta-fg: #1e1e1e;
106
+ --term-cta-bg-hover: #e5e5e5;
107
+ --term-tabstrip: #252526;
108
+ --term-tab: #2d2d2d;
109
+ --term-tab-hover: #333333;
110
+ --term-tab-text: rgba(204, 204, 204, 0.65);
111
+ --term-keybar-bg: #252526;
112
+ --term-key-fg: #cccccc;
113
+ --term-key-bg: rgba(255, 255, 255, 0.06);
114
+ --term-key-border: rgba(255, 255, 255, 0.14);
115
+ --term-key-active-bg: rgba(255, 255, 255, 0.20);
116
+ --term-key-active-border: rgba(255, 255, 255, 0.32);
117
+ --term-key-hint: rgba(204, 204, 204, 0.5);
118
+ --term-pop-bg: #252526;
119
+ --term-pop-border: rgba(255, 255, 255, 0.16);
120
+ }
@@ -103,6 +103,36 @@ textarea {
103
103
  line-height: 1.55;
104
104
  }
105
105
 
106
+ /* Segmented control — a row of mutually-exclusive pills sharing one
107
+ border (used for the Appearance light/dark/system toggle). */
108
+ .seg {
109
+ display: inline-flex;
110
+ padding: 2px;
111
+ gap: 2px;
112
+ background: var(--ui-bg);
113
+ border: 1px solid var(--border-strong);
114
+ border-radius: 8px;
115
+ }
116
+ .seg-btn {
117
+ appearance: none;
118
+ background: transparent;
119
+ border: 0;
120
+ color: var(--ink-mid);
121
+ font-family: var(--body);
122
+ font-size: 12.5px;
123
+ font-weight: 500;
124
+ padding: 5px 14px;
125
+ border-radius: 6px;
126
+ cursor: pointer;
127
+ transition: background .14s ease, color .14s ease;
128
+ }
129
+ .seg-btn:hover { color: var(--ink); }
130
+ .seg-btn.is-active {
131
+ background: var(--bg-elev);
132
+ color: var(--ink);
133
+ box-shadow: var(--shadow-sm);
134
+ }
135
+
106
136
  input[type="checkbox"] {
107
137
  appearance: none;
108
138
  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; }
@@ -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');
@@ -199,7 +199,7 @@ export const StarSmallFilled = ({ size = 14 } = {}) => html`
199
199
  // brand mark (terminal window + ccsm text — matches /favicon.svg)
200
200
  export const BrandMark = () => html`
201
201
  <svg viewBox="0 0 32 32" width="32" height="32">
202
- <rect x="2" y="4" width="28" height="24" rx="3" fill="#1a1815"/>
202
+ <rect class="brand-rect" x="2" y="4" width="28" height="24" rx="3" fill="#1a1815"/>
203
203
  <line x1="2" y1="10" x2="30" y2="10" stroke="#faf9f5" stroke-width="0.6" opacity="0.45"/>
204
204
  <!-- macOS traffic-light style: red / yellow / green -->
205
205
  <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,
@@ -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,23 @@ function RestartButton() {
577
581
  `;
578
582
  }
579
583
 
584
+ function ThemeToggle() {
585
+ const mode = themeMode.value;
586
+ const opts = [
587
+ { id: 'light', label: 'Light' },
588
+ { id: 'dark', label: 'Dark' },
589
+ { id: 'system', label: 'System' },
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)}>${o.label}</button>`)}
598
+ </div>`;
599
+ }
600
+
580
601
  function AccentPicker() {
581
602
  const current = (accentColor.value || '').toLowerCase();
582
603
  const matchedPreset = PRESETS.find((p) => p.hex.toLowerCase() === current);
@@ -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) {