@bakapiano/ccsm 0.19.3 → 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/lib/config.js +9 -0
- package/lib/tunnel.js +149 -3
- 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/css/widgets.css +42 -0
- package/public/favicon.svg +8 -1
- package/public/index.html +56 -22
- package/public/js/components/TerminalView.js +109 -28
- package/public/js/icons.js +1 -1
- package/public/js/pages/ConfigurePage.js +23 -2
- package/public/js/pages/LaunchPage.js +51 -6
- package/public/js/pages/RemotePage.js +88 -20
- package/public/js/state.js +107 -38
- package/server.js +20 -0
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) {
|
package/server.js
CHANGED
|
@@ -1092,6 +1092,20 @@ app.post('/api/tunnel/devtunnel/login/dismiss', asyncH(async (_req, res) => {
|
|
|
1092
1092
|
tunnel.clearDevtunnelLogin();
|
|
1093
1093
|
res.json({ ok: true });
|
|
1094
1094
|
}));
|
|
1095
|
+
// Wipe the persisted devtunnel tunnel id (and the remote tunnel
|
|
1096
|
+
// resource itself, best-effort) so the next /api/tunnel/start mints
|
|
1097
|
+
// a fresh one. Used by the Reset button in the Remote page when the
|
|
1098
|
+
// user wants to rotate the public URL. Tunnel must be stopped first
|
|
1099
|
+
// — refuse otherwise so we don't yank state out from under a live
|
|
1100
|
+
// `devtunnel host` child.
|
|
1101
|
+
app.post('/api/tunnel/devtunnel/reset', asyncH(async (_req, res) => {
|
|
1102
|
+
const s = await tunnel.status();
|
|
1103
|
+
if (s.running && s.provider === 'devtunnel') {
|
|
1104
|
+
return res.status(409).json({ error: 'stop the tunnel before resetting its id' });
|
|
1105
|
+
}
|
|
1106
|
+
const r = await tunnel.resetDevtunnelTunnelId();
|
|
1107
|
+
res.json({ ok: true, ...r, ...(await tunnel.status()) });
|
|
1108
|
+
}));
|
|
1095
1109
|
|
|
1096
1110
|
// ---- devices ----
|
|
1097
1111
|
//
|
|
@@ -1555,6 +1569,12 @@ function openInBrowser(url) {
|
|
|
1555
1569
|
// a slow Import dialog cold-open. Fire in the background; the lib also
|
|
1556
1570
|
// starts its own 15s refresh loop.
|
|
1557
1571
|
try { localCliSessions.prewarmLivePids(['claude.exe']); } catch {}
|
|
1572
|
+
// Prewarm tunnel provider probe. First /api/tunnel/status round-trip
|
|
1573
|
+
// shells out to where.exe / --version / devtunnel user show — ~700ms
|
|
1574
|
+
// of synchronous work that the user otherwise waits on the moment
|
|
1575
|
+
// they open the Remote tab. Fire in the background here so the cache
|
|
1576
|
+
// is warm by the time anyone clicks.
|
|
1577
|
+
try { tunnel.probe(true).catch(() => {}); } catch {}
|
|
1558
1578
|
|
|
1559
1579
|
if (webTerminal.available) {
|
|
1560
1580
|
let WebSocketServer;
|