@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 +1 -1
- package/public/css/dark.css +120 -0
- package/public/css/forms.css +30 -0
- package/public/css/layout.css +4 -5
- package/public/css/terminals.css +71 -38
- package/public/favicon.svg +8 -1
- package/public/index.html +56 -22
- package/public/js/components/TerminalView.js +71 -24
- package/public/js/icons.js +1 -1
- package/public/js/pages/ConfigurePage.js +23 -2
- package/public/js/state.js +107 -38
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakapiano/ccsm",
|
|
3
|
-
"version": "0.
|
|
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
|
+
}
|
package/public/css/forms.css
CHANGED
|
@@ -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;
|
package/public/css/layout.css
CHANGED
|
@@ -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
|
-
/*
|
|
129
|
-
|
|
130
|
-
divider. */
|
|
131
|
-
|
|
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 exactly — same
|
|
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;
|
package/public/css/terminals.css
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
324
|
+
.session-tab:hover { background: var(--term-tab-hover); color: var(--term-on); }
|
|
292
325
|
.session-tab.is-active {
|
|
293
|
-
background: var(--
|
|
294
|
-
color:
|
|
295
|
-
border-bottom-color: var(--
|
|
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:
|
|
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:
|
|
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:
|
|
422
|
-
color:
|
|
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:
|
|
465
|
+
color: var(--term-prompt);
|
|
433
466
|
}
|
|
434
467
|
.session-pane-body .terminal-empty .action.primary {
|
|
435
|
-
background:
|
|
436
|
-
color:
|
|
437
|
-
border-color:
|
|
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:
|
|
441
|
-
border-color:
|
|
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:
|
|
450
|
-
color:
|
|
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:
|
|
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:
|
|
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:
|
|
484
|
-
color:
|
|
485
|
-
border-color:
|
|
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:
|
|
491
|
-
border-color:
|
|
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:
|
|
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:
|
|
509
|
-
border-top: 1px solid
|
|
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:
|
|
542
|
-
background:
|
|
543
|
-
border: 1px solid
|
|
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:
|
|
551
|
-
border-color:
|
|
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:
|
|
574
|
-
border: 1px solid
|
|
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:
|
|
586
|
-
.tkb-combo-hint { font-size: 9.5px; color:
|
|
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; }
|
package/public/favicon.svg
CHANGED
|
@@ -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"
|
|
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
|
|
31
|
-
of the default
|
|
32
|
-
applyAccentCssVars() in state.js
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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
|
|
44
|
-
var
|
|
45
|
-
var
|
|
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(
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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:
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
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');
|
package/public/js/icons.js
CHANGED
|
@@ -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);
|
package/public/js/state.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
|
190
|
-
const
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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) {
|