@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.
@@ -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) {
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;