@bakapiano/ccsm 0.8.3 → 0.9.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.
@@ -7,6 +7,8 @@ import { useEffect, useRef } from 'preact/hooks';
7
7
  import { Terminal } from '@xterm/xterm';
8
8
  import { FitAddon } from '@xterm/addon-fit';
9
9
  import { WebLinksAddon } from '@xterm/addon-web-links';
10
+ import { ClipboardAddon } from '@xterm/addon-clipboard';
11
+ import { WebglAddon } from '@xterm/addon-webgl';
10
12
  import { wsBase } from '../backend.js';
11
13
 
12
14
  // Dark xterm theme. We give the terminal a near-black ink background to
@@ -47,10 +49,43 @@ export function TerminalView({ terminalId }) {
47
49
  scrollback: 5000,
48
50
  allowProposedApi: true,
49
51
  theme: THEME,
52
+ // Modern keyboard protocols. Without these, xterm.js encodes
53
+ // Shift+Enter, Ctrl+Enter, Ctrl+Shift+key etc. the same as their
54
+ // unmodified versions (e.g. both Enter and Shift+Enter send \r),
55
+ // so TUIs like claude code can't tell them apart.
56
+ //
57
+ // - kittyKeyboard: opt-in protocol that apps enable per-session;
58
+ // xterm emits CSI u sequences that uniquely encode every modifier
59
+ // combo. Claude / vim / fish recognise it.
60
+ // - win32InputMode: ConPTY-specific protocol that surfaces raw
61
+ // Win32 KEY_EVENT_RECORD to the child process, again preserving
62
+ // modifier info. Required for full key fidelity on Windows.
63
+ // (Same set VSCode enables — see vscode/src/.../xtermTerminal.ts)
64
+ vtExtensions: {
65
+ kittyKeyboard: true,
66
+ win32InputMode: true,
67
+ },
50
68
  });
51
69
  const fit = new FitAddon();
52
70
  term.loadAddon(fit);
53
71
  term.loadAddon(new WebLinksAddon());
72
+ // OSC 52 clipboard integration. Lets TUI apps initiate clipboard reads/
73
+ // writes via escape sequences (e.g. `tmux set-buffer` or claude code
74
+ // saying "copied to clipboard"). Does NOT handle the browser-side
75
+ // Ctrl+V — that's still our document-level paste handler below.
76
+ term.loadAddon(new ClipboardAddon());
77
+ // WebGL renderer for performance. The default DOM renderer struggles
78
+ // when claude code produces dense color output (its diff panels,
79
+ // syntax-highlighted code). WebGL paints onto a canvas, much smoother
80
+ // at thousands-of-cells per frame. Falls back to DOM if WebGL is
81
+ // unavailable (e.g. older GPU, hardware accel disabled).
82
+ try {
83
+ const webgl = new WebglAddon();
84
+ webgl.onContextLoss(() => { try { webgl.dispose(); } catch {} });
85
+ term.loadAddon(webgl);
86
+ } catch (e) {
87
+ console.warn('[ccsm] WebGL addon failed, using DOM renderer:', e);
88
+ }
54
89
  term.open(hostRef.current);
55
90
  // Defer fit one tick so the container has measured layout
56
91
  requestAnimationFrame(() => { try { fit.fit(); } catch {} });
@@ -92,7 +127,138 @@ export function TerminalView({ terminalId }) {
92
127
  // give focus to terminal so user can type immediately
93
128
  term.focus();
94
129
 
130
+ // Explicit paste handler. xterm.js relies on the browser routing paste
131
+ // events to its hidden .xterm-helper-textarea, which only works if that
132
+ // textarea has focus at the moment of Ctrl+V. When the user clicks
133
+ // elsewhere then hits Ctrl+V over the terminal, or pastes via the
134
+ // right-click menu on the host div, the event lands on the host and
135
+ // xterm never sees it. Catch it here and route through term.paste()
136
+ // so xterm wraps the text in bracketed-paste markers when the app
137
+ // (claude code) has DECSET 2004 enabled — that's what makes claude
138
+ // show the "[Pasted text]" affordance instead of treating it as
139
+ // typed input.
140
+ const isOurs = () => {
141
+ const ae = document.activeElement;
142
+ return ae && host.contains(ae);
143
+ };
144
+ const doPaste = (text) => {
145
+ if (!text) return;
146
+ if (ws.readyState !== 1) return;
147
+ // Normalize line endings to \r (CR / Enter). This mirrors VSCode's
148
+ // terminal sendText path (terminalInstance.ts ~L1385):
149
+ // text = text.replace(/\r?\n/g, '\r');
150
+ // Bracketed-paste markers protect each \r from being interpreted
151
+ // as a submit by the host app — claude / pwsh / vim all treat
152
+ // bracketed contents as opaque payload regardless of what's inside.
153
+ // Use \n instead and you trip apps that look for "real" line breaks.
154
+ const normalized = text.replace(/\r?\n/g, '\r');
155
+ // Wrap in bracketed-paste markers. Claude Code enables DECSET 2004
156
+ // on startup, so the markers let it detect a paste and render
157
+ // "[Pasted text]". If the host app doesn't have bracketed paste on,
158
+ // it just sees two ignored escape sequences plus the text.
159
+ const wrapped = `\x1b[200~${normalized}\x1b[201~`;
160
+ ws.send(JSON.stringify({ type: 'input', data: wrapped }));
161
+ };
162
+ const onPaste = async (ev) => {
163
+ if (!isOurs()) return;
164
+ let text = '';
165
+ if (ev.clipboardData) text = ev.clipboardData.getData('text');
166
+ if (!text && navigator.clipboard) {
167
+ try { text = await navigator.clipboard.readText(); } catch {}
168
+ }
169
+ if (!text) return;
170
+ ev.preventDefault();
171
+ ev.stopPropagation();
172
+ doPaste(text);
173
+ };
174
+ document.addEventListener('paste', onPaste, true);
175
+
176
+ // Ctrl/Cmd+V fallback for cases the paste event is suppressed (some
177
+ // extensions, or when our IME workaround moved the helper textarea
178
+ // off-screen and the browser refuses to fire paste on it).
179
+ // IMPORTANT: preventDefault must happen synchronously, BEFORE the
180
+ // await on navigator.clipboard.readText(). If we let the event tick
181
+ // run first, xterm's keystroke handler converts Ctrl+V into the raw
182
+ // ^V (0x16) control byte and ships it before our async paste even
183
+ // resolves.
184
+ const onKey = (ev) => {
185
+ const meta = ev.ctrlKey || ev.metaKey;
186
+ if (!meta || ev.key.toLowerCase() !== 'v') return;
187
+ if (ev.shiftKey || ev.altKey) return;
188
+ if (!isOurs()) return;
189
+ if (!navigator.clipboard?.readText) return;
190
+ ev.preventDefault();
191
+ ev.stopPropagation();
192
+ ev.stopImmediatePropagation();
193
+ navigator.clipboard.readText().then((text) => {
194
+ if (text) doPaste(text);
195
+ }).catch(() => {});
196
+ };
197
+ document.addEventListener('keydown', onKey, true);
198
+
199
+ // Shift+Enter / Ctrl+Enter → insert literal newline, don't submit.
200
+ // Background: xterm.js encodes BOTH plain Enter and Shift+Enter and
201
+ // Ctrl+Enter as \r (0x0D / CR). The kitty keyboard / win32 input
202
+ // protocols (enabled in vtExtensions above) WOULD distinguish them,
203
+ // but they're opt-in by the running app — claude code doesn't enable
204
+ // either, so we never get the distinction "for free".
205
+ //
206
+ // Send the LF (0x0A) explicitly. Claude code (and most modern TUIs)
207
+ // treat \n inside a prompt as a literal newline insert, \r as submit.
208
+ // Alt+Enter already works (xterm sends \x1b\r → meta-enter) so we
209
+ // leave that alone.
210
+ const onShiftEnter = (ev) => {
211
+ if (ev.key !== 'Enter') return;
212
+ if (!(ev.shiftKey || ev.ctrlKey)) return;
213
+ if (ev.metaKey || ev.altKey) return;
214
+ if (!isOurs()) return;
215
+ ev.preventDefault();
216
+ ev.stopPropagation();
217
+ ev.stopImmediatePropagation();
218
+ if (ws.readyState === 1) {
219
+ ws.send(JSON.stringify({ type: 'input', data: '\n' }));
220
+ }
221
+ };
222
+ document.addEventListener('keydown', onShiftEnter, true);
223
+
224
+ // IME fix: xterm positions .xterm-helper-textarea via `left: <col-px>`
225
+ // following the cursor. When the cursor is near the right edge and the
226
+ // user starts composing (e.g. Chinese pinyin), the textarea + native
227
+ // composition popup grow with the composed string and overflow the
228
+ // terminal host — which visually pushes the layout right. We can't cap
229
+ // width / change wrapping (that breaks Chromium's IME event flow), but
230
+ // we CAN re-anchor the textarea to the right edge while composing so
231
+ // it grows leftward instead. Toggling a class on the host is enough;
232
+ // the CSS in terminals.css does the rest.
233
+ const host = hostRef.current;
234
+ const onCompStart = () => {
235
+ if (host) host.classList.add('is-composing');
236
+ // The terminal cursor is rendered on canvas (THEME.cursor), so CSS
237
+ // can't hide it. Theme swap alone doesn't reliably stop the blink
238
+ // frame loop, so also issue the DECTCEM hide sequence which the
239
+ // renderer honours immediately.
240
+ try { term.options.theme = { ...THEME, cursor: 'transparent', cursorAccent: 'transparent' }; } catch {}
241
+ try { term.write('\x1b[?25l'); } catch {}
242
+ };
243
+ const onCompEnd = () => {
244
+ if (host) host.classList.remove('is-composing');
245
+ try { term.options.theme = THEME; } catch {}
246
+ try { term.write('\x1b[?25h'); } catch {}
247
+ };
248
+ const helper = host?.querySelector('.xterm-helper-textarea');
249
+ if (helper) {
250
+ helper.addEventListener('compositionstart', onCompStart);
251
+ helper.addEventListener('compositionend', onCompEnd);
252
+ }
253
+
95
254
  return () => {
255
+ document.removeEventListener('paste', onPaste, true);
256
+ document.removeEventListener('keydown', onKey, true);
257
+ document.removeEventListener('keydown', onShiftEnter, true);
258
+ if (helper) {
259
+ helper.removeEventListener('compositionstart', onCompStart);
260
+ helper.removeEventListener('compositionend', onCompEnd);
261
+ }
96
262
  ro.disconnect();
97
263
  try { ws.close(); } catch {}
98
264
  try { term.dispose(); } catch {}
@@ -4,7 +4,7 @@ import { setToast } from '../toast.js';
4
4
  import { Card } from '../components/Card.js';
5
5
  import { BrandMark, IconGithub, IconExternal } from '../icons.js';
6
6
 
7
- const REPO_URL = 'https://github.com/bakapiano/cssm';
7
+ const REPO_URL = 'https://github.com/bakapiano/ccsm';
8
8
  const NPM_URL = 'https://www.npmjs.com/package/@bakapiano/ccsm';
9
9
 
10
10
  async function onInstall() {
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { html } from '../html.js';
7
7
  import { useEffect, useState } from 'preact/hooks';
8
- import { config, terminals, configDirty } from '../state.js';
8
+ import { config, terminals, configDirty, accentColor, setAccentColor, ACCENT_DEFAULT } from '../state.js';
9
9
  import { api, loadWorkspaces } from '../api.js';
10
10
  import { setToast } from '../toast.js';
11
11
  import { ccsmConfirm } from '../dialog.js';
@@ -22,6 +22,7 @@ function defaultsFrom(cfg) {
22
22
  claudeCommand: cfg.claudeCommand || 'claude',
23
23
  terminal: cfg.terminal,
24
24
  commandShell: cfg.commandShell || 'pwsh',
25
+ defaultTerminalMode: cfg.defaultTerminalMode || 'wt',
25
26
  autoFocusOnLaunch: cfg.autoFocusOnLaunch !== false,
26
27
  focusMovesToCenter: cfg.focusMovesToCenter === true,
27
28
  browserMode: cfg.browserMode || (cfg.autoOpenBrowser === false ? 'none' : 'app'),
@@ -62,6 +63,7 @@ export function ConfigurePage() {
62
63
  claudeCommand: (draft.claudeCommand || 'claude').trim(),
63
64
  terminal: draft.terminal || 'wt',
64
65
  commandShell: draft.commandShell || 'pwsh',
66
+ defaultTerminalMode: draft.defaultTerminalMode === 'web' ? 'web' : 'wt',
65
67
  browserMode: draft.browserMode || 'app',
66
68
  workDir: (draft.workDir || '').trim(),
67
69
  repos: (cfg.repos || []).filter((r) => r.name && r.url),
@@ -127,6 +129,15 @@ export function ConfigurePage() {
127
129
  onInput=${(e) => update({ claudeCommand: e.target.value })} />
128
130
  <span class="hint">alias / function / exe name</span>
129
131
  </label>
132
+ <label class="field">
133
+ <span class="label">Default mode <span class="hint inline">(new · resume · continue · finder)</span></span>
134
+ <select class="input" value=${draft.defaultTerminalMode}
135
+ onChange=${(e) => update({ defaultTerminalMode: e.target.value })}>
136
+ <option value="wt">system terminal · open a real ${draft.terminal || 'wt'} window</option>
137
+ <option value="web">web · in-page xterm under the Terminals tab</option>
138
+ </select>
139
+ <span class="hint">web requires node-pty; per-launch radios can override</span>
140
+ </label>
130
141
  <label class="field">
131
142
  <span class="label">Terminal</span>
132
143
  <select class="input" value=${draft.terminal}
@@ -176,6 +187,12 @@ export function ConfigurePage() {
176
187
  <span class="hint">passed as initial prompt to the finder session</span>
177
188
  </label>
178
189
 
190
+ <div class="field">
191
+ <span class="label">Theme accent</span>
192
+ <${AccentPicker} />
193
+ <span class="hint">also tints the OS title bar (theme-color)</span>
194
+ </div>
195
+
179
196
  <div class="field full">
180
197
  <div class="repos-head">
181
198
  <span class="label">Repositories</span>
@@ -192,3 +209,57 @@ export function ConfigurePage() {
192
209
  </div>
193
210
  </${Card}>`;
194
211
  }
212
+
213
+ // Curated preset palette + free hex input. Each preset is a hand-picked
214
+ // brand color that reads well on the cream surface. Selecting a swatch
215
+ // applies immediately via setAccentColor (which writes CSS vars +
216
+ // localStorage) — no save button needed since this is a per-browser
217
+ // UI preference, not part of the server-side config.
218
+ const PRESETS = [
219
+ { name: 'Claude copper', hex: '#b3614a' }, // default
220
+ { name: 'Anthropic ink', hex: '#1a1815' },
221
+ { name: 'Ocean', hex: '#2f6fa3' },
222
+ { name: 'Forest', hex: '#3f7a4a' },
223
+ { name: 'Amber', hex: '#c4892b' },
224
+ { name: 'Berry', hex: '#a44b78' },
225
+ { name: 'Slate', hex: '#4a5563' },
226
+ { name: 'Crimson', hex: '#b73f3f' },
227
+ ];
228
+
229
+ function AccentPicker() {
230
+ const current = accentColor.value;
231
+ const [text, setText] = useState(current);
232
+ // Keep the text input in sync if the signal changes from elsewhere
233
+ // (preset click, reset). useState would otherwise drift on subsequent
234
+ // applies. eslint-disable-next-line — intentionally re-syncing on prop change.
235
+ useEffect(() => { setText(current); }, [current]);
236
+
237
+ const onText = (e) => {
238
+ const v = e.target.value.trim();
239
+ setText(v);
240
+ // Apply live only when it's a valid hex; otherwise let the user
241
+ // keep typing without flicker.
242
+ if (/^#[0-9a-fA-F]{6}$/.test(v)) setAccentColor(v);
243
+ };
244
+
245
+ return html`
246
+ <div class="accent-picker">
247
+ <div class="accent-swatches">
248
+ ${PRESETS.map((p) => html`
249
+ <button key=${p.hex} class=${`accent-swatch${current.toLowerCase() === p.hex.toLowerCase() ? ' is-active' : ''}`}
250
+ style=${`background:${p.hex}`}
251
+ title=${`${p.name} · ${p.hex}`}
252
+ aria-label=${p.name}
253
+ onClick=${() => setAccentColor(p.hex)}></button>`)}
254
+ </div>
255
+ <div class="accent-custom">
256
+ <input type="color" value=${current}
257
+ onInput=${(e) => setAccentColor(e.target.value)} />
258
+ <input type="text" class="accent-hex" value=${text}
259
+ spellcheck="false" maxlength="7"
260
+ onInput=${onText} placeholder="#rrggbb" />
261
+ <button class="action subtle small"
262
+ onClick=${() => setAccentColor(ACCENT_DEFAULT)}>Reset</button>
263
+ </div>
264
+ </div>`;
265
+ }
@@ -1,12 +1,13 @@
1
1
  import { html } from '../html.js';
2
2
  import { useState } from 'preact/hooks';
3
3
  import { signal } from '@preact/signals';
4
- import { capabilities, activeTerminalId, selectTab } from '../state.js';
5
- import { loadWorkspaces, loadWebTerminals } from '../api.js';
4
+ import { capabilities, activeTerminalId, selectTab, config } from '../state.js';
5
+ import { api, loadWorkspaces, loadWebTerminals } from '../api.js';
6
6
  import { setToast } from '../toast.js';
7
7
  import { streamNewSession, resetProgress } from '../streaming.js';
8
8
  import { Card } from '../components/Card.js';
9
9
  import { RepoPicker } from '../components/RepoPicker.js';
10
+ import { ReposEditor, addEmptyRepo } from '../components/ReposEditor.js';
10
11
  import { WorkspacePicker } from '../components/WorkspacePicker.js';
11
12
  import { ProgressList } from '../components/ProgressList.js';
12
13
  import { WorkspacesGrid, WorkspacesHeader } from '../components/WorkspacesGrid.js';
@@ -17,16 +18,30 @@ const inlineSelected = signal(new Set());
17
18
 
18
19
  function NewSessionCard() {
19
20
  const [workspace, setWorkspace] = useState('');
20
- // 'web' = run inside this page (in-process PTY · bridges to xterm.js)
21
- // 'wt' = open a new Windows Terminal window
22
- const initialMode = capabilities.value.webTerminal ? 'web' : 'wt';
23
- const [terminal, setTerminal] = useState(initialMode);
24
21
  const [result, setResult] = useState('');
25
22
  const [busy, setBusy] = useState(false);
23
+ const [reposSavedAt, setReposSavedAt] = useState('');
24
+ const repos = config.value?.repos || [];
25
+ const hasRepos = repos.length > 0;
26
+ // Always follow the global Configure → "Default mode" setting. The
27
+ // per-launch picker was removed; users who want a one-off override
28
+ // change the global setting first. This keeps "new" / "resume" /
29
+ // "continue" / "finder" all consistent.
30
+ const cfgDefault = config.value?.defaultTerminalMode || 'wt';
31
+ const terminal = capabilities.value.webTerminal ? cfgDefault : 'wt';
32
+
33
+ const onSaveRepos = async () => {
34
+ try {
35
+ const cfg = await api('PUT', '/api/config', config.value);
36
+ config.value = cfg;
37
+ setReposSavedAt(`saved · ${new Date().toLocaleTimeString(undefined, { hour12: false })}`);
38
+ setToast('repos saved');
39
+ } catch (e) { setToast(e.message, 'error'); }
40
+ };
26
41
 
27
42
  const onLaunch = async () => {
28
43
  const repos = [...inlineSelected.value];
29
- if (repos.length === 0) return setToast('select at least one repo', 'error');
44
+ // Allow zero-repo launches: workspace is created empty, claude opens there.
30
45
  setBusy(true);
31
46
  setResult('');
32
47
  resetProgress(repos, ROOT_ID);
@@ -77,24 +92,21 @@ function NewSessionCard() {
77
92
  meta=${html`Picks an unused workspace, clones missing repos, opens <code>claude</code> in a fresh terminal.`}>
78
93
  <div class="form-row">
79
94
  <span class="form-label">Repos</span>
80
- <${RepoPicker} selectedSig=${inlineSelected} />
95
+ ${hasRepos
96
+ ? html`<${RepoPicker} selectedSig=${inlineSelected} />`
97
+ : html`<span class="muted-text">no repos configured · add one below, or launch with no repos for an empty workspace</span>`}
81
98
  </div>
82
- ${capabilities.value.webTerminal ? html`
83
- <div class="form-row">
84
- <span class="form-label">Open in</span>
85
- <div class="radio-row">
86
- <label class=${`radio${terminal === 'web' ? ' is-checked' : ''}`}>
87
- <input type="radio" name="terminal" value="web"
88
- checked=${terminal === 'web'} onChange=${() => setTerminal('web')} />
89
- this page
90
- </label>
91
- <label class=${`radio${terminal === 'wt' ? ' is-checked' : ''}`}>
92
- <input type="radio" name="terminal" value="wt"
93
- checked=${terminal === 'wt'} onChange=${() => setTerminal('wt')} />
94
- wt window
95
- </label>
99
+ <details class="repos-inline-config" open=${!hasRepos}>
100
+ <summary>Manage repos</summary>
101
+ <div class="repos-inline-body">
102
+ <${ReposEditor} />
103
+ <div class="repos-inline-actions">
104
+ <button class="action small" onClick=${() => addEmptyRepo()}>+ Add repo</button>
105
+ <button class="action small primary" onClick=${onSaveRepos}>Save changes</button>
106
+ <span class="muted-text">${reposSavedAt}</span>
96
107
  </div>
97
- </div>` : null}
108
+ </div>
109
+ </details>
98
110
  <div class="form-row">
99
111
  <label class="form-label">Workspace</label>
100
112
  <${WorkspacePicker} value=${workspace} onChange=${setWorkspace} />
@@ -22,6 +22,8 @@ export const serverHealth = signal({ state: 'connecting' });
22
22
  // ── ui state (persisted in localStorage where noted) ───────────
23
23
  export const activeTab = signal('sessions');
24
24
  export const sidebarCollapsed = signal(false);
25
+ export const sidebarWidth = signal(232); // px when expanded, persisted in localStorage
26
+ export const accentColor = signal('#b3614a'); // user-chosen brand accent, persisted
25
27
  // fold state for the three cards on the Sessions tab
26
28
  export const cardFolded = signal({ favorites: false, sessions: false, recent: false });
27
29
  export const configDirty = signal(false);
@@ -54,10 +56,29 @@ export const TAB_HEADINGS = {
54
56
 
55
57
  // ── persistence helpers (localStorage) ──────────────────────────
56
58
  const LS_SIDEBAR = 'ccsm.sidebar-collapsed';
59
+ const LS_SIDEBAR_W = 'ccsm.sidebar-width';
60
+ const LS_ACCENT = 'ccsm.accent';
57
61
  const LS_FOLD = (k) => `ccsm.fold.${k}`;
58
62
 
63
+ // Resizable sidebar width (when not collapsed). Clamp range matches the
64
+ // CSS min/max — too narrow truncates labels, too wide eats main content.
65
+ export const SIDEBAR_MIN = 180;
66
+ export const SIDEBAR_MAX = 400;
67
+ export const SIDEBAR_DEFAULT = 232;
68
+ export const ACCENT_DEFAULT = '#b3614a';
69
+
59
70
  export function loadPersisted() {
60
71
  sidebarCollapsed.value = localStorage.getItem(LS_SIDEBAR) === 'true';
72
+ const w = Number(localStorage.getItem(LS_SIDEBAR_W));
73
+ if (Number.isFinite(w) && w >= SIDEBAR_MIN && w <= SIDEBAR_MAX) {
74
+ sidebarWidth.value = w;
75
+ }
76
+ applySidebarWidthCssVar();
77
+ const a = localStorage.getItem(LS_ACCENT);
78
+ if (isHexColor(a)) {
79
+ accentColor.value = a;
80
+ }
81
+ applyAccentCssVars();
61
82
  const folds = { ...cardFolded.value };
62
83
  for (const k of Object.keys(folds)) {
63
84
  folds[k] = localStorage.getItem(LS_FOLD(k)) === '1';
@@ -68,6 +89,100 @@ export function loadPersisted() {
68
89
  if (TAB_HEADINGS[hash]) activeTab.value = hash;
69
90
  }
70
91
 
92
+ // Push the current sidebar width into the CSS custom property so the grid
93
+ // in layout.css picks it up. Called on load and whenever the user drags
94
+ // the handle.
95
+ function applySidebarWidthCssVar() {
96
+ document.documentElement.style.setProperty('--sidebar-w', `${sidebarWidth.value}px`);
97
+ }
98
+
99
+ export function setSidebarWidth(px) {
100
+ const clamped = Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, Math.round(px)));
101
+ sidebarWidth.value = clamped;
102
+ applySidebarWidthCssVar();
103
+ localStorage.setItem(LS_SIDEBAR_W, String(clamped));
104
+ }
105
+
106
+ // ── theme accent ────────────────────────────────────────────────
107
+ // We expose 4 derived CSS vars: --accent, --accent-deep, --accent-soft,
108
+ // --accent-softer. The user only picks the base; deep/soft are computed
109
+ // (darken / rgba alpha) so things stay self-consistent.
110
+ function isHexColor(s) {
111
+ return typeof s === 'string' && /^#[0-9a-fA-F]{6}$/.test(s);
112
+ }
113
+ function hexToRgb(hex) {
114
+ const n = parseInt(hex.slice(1), 16);
115
+ return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
116
+ }
117
+ function rgbToHex({ r, g, b }) {
118
+ const h = (n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0');
119
+ return `#${h(r)}${h(g)}${h(b)}`;
120
+ }
121
+ function darken({ r, g, b }, amount) {
122
+ // amount 0..1; pull each channel toward 0
123
+ return { r: r * (1 - amount), g: g * (1 - amount), b: b * (1 - amount) };
124
+ }
125
+ function lighten({ r, g, b }, amount) {
126
+ // amount 0..1; pull each channel toward 255
127
+ return { r: r + (255 - r) * amount, g: g + (255 - g) * amount, b: b + (255 - b) * amount };
128
+ }
129
+ // Mix the accent into white at a tiny ratio to get a faint warm/cool tint
130
+ // for surfaces. `t` controls strength (0 = pure white, 1 = pure accent).
131
+ // Surfaces use very low t (0.02–0.08) so the page reads as "white with
132
+ // a hint of the brand color" rather than colored.
133
+ function mixWithWhite({ r, g, b }, t) {
134
+ return { r: r * t + 255 * (1 - t), g: g * t + 255 * (1 - t), b: b * t + 255 * (1 - t) };
135
+ }
136
+ function applyAccentCssVars() {
137
+ const base = accentColor.value;
138
+ const rgb = hexToRgb(base);
139
+ const deep = rgbToHex(darken(rgb, 0.2));
140
+ const soft = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.10)`;
141
+ const softer = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.04)`;
142
+ // Surface tints derived from the accent. Each surface keeps its
143
+ // relative brightness from the original palette (cream → white → cream
144
+ // hover → cream active) but its hue follows the chosen accent. Mixed
145
+ // weights chosen to roughly match the warm copper defaults:
146
+ // --bg was #faf9f5 → t≈0.04
147
+ // --bg-elev was #ffffff → t=0 (kept pure white)
148
+ // --sidebar-hover was #f0ece0 → t≈0.10
149
+ // --sidebar-active was #e8e3d5 → t≈0.15
150
+ // --border was #e8e3d5 → t≈0.15
151
+ // --border-soft was #ece8da → t≈0.12
152
+ // --border-strong was #d4cdb8 → t≈0.25
153
+ const bg = rgbToHex(mixWithWhite(rgb, 0.04));
154
+ const sidebarHover = rgbToHex(mixWithWhite(rgb, 0.10));
155
+ const sidebarActive= rgbToHex(mixWithWhite(rgb, 0.15));
156
+ const border = rgbToHex(mixWithWhite(rgb, 0.15));
157
+ const borderSoft = rgbToHex(mixWithWhite(rgb, 0.12));
158
+ const borderStrong = rgbToHex(mixWithWhite(rgb, 0.25));
159
+ const root = document.documentElement.style;
160
+ root.setProperty('--accent', base);
161
+ root.setProperty('--accent-deep', deep);
162
+ root.setProperty('--accent-soft', soft);
163
+ root.setProperty('--accent-softer', softer);
164
+ root.setProperty('--bg', bg);
165
+ root.setProperty('--sidebar-bg', bg);
166
+ root.setProperty('--sidebar-hover', sidebarHover);
167
+ root.setProperty('--sidebar-active', sidebarActive);
168
+ root.setProperty('--border', border);
169
+ root.setProperty('--border-soft', borderSoft);
170
+ root.setProperty('--border-strong', borderStrong);
171
+ // --bg-elev stays pure white so cards "lift" off the tinted surface.
172
+ // Sync the meta theme-color to the tinted surface so the OS title bar
173
+ // matches what the user sees (was previously the raw accent — too
174
+ // saturated).
175
+ const meta = document.querySelector('meta[name="theme-color"]');
176
+ if (meta) meta.setAttribute('content', bg);
177
+ }
178
+
179
+ export function setAccentColor(hex) {
180
+ if (!isHexColor(hex)) return;
181
+ accentColor.value = hex;
182
+ applyAccentCssVars();
183
+ localStorage.setItem(LS_ACCENT, hex);
184
+ }
185
+
71
186
  // ── actions ─────────────────────────────────────────────────────
72
187
  export function selectTab(name) {
73
188
  if (!TAB_HEADINGS[name]) name = 'sessions';
@@ -3,7 +3,7 @@
3
3
 
4
4
  // ccsm postinstall · Windows-only · runs after `npm install -g @bakapiano/ccsm`.
5
5
  // Registers the `ccsm://` URL protocol in HKCU so the hosted frontend
6
- // (https://bakapiano.github.io/cssm/v1/) can fire `<a href="ccsm://start">`
6
+ // (https://bakapiano.github.io/ccsm/v1/) can fire `<a href="ccsm://start">`
7
7
  // from its OfflineBanner and have Windows spawn the backend on demand.
8
8
  //
9
9
  // Best-effort: any failure MUST NOT break npm install. Each step is in
@@ -104,8 +104,29 @@ try {
104
104
  registerProtocol(vbsPath);
105
105
  log(`launcher · ${vbsPath}`);
106
106
  log(`ccsm:// protocol registered (silent · via wscript.exe)`);
107
- log('open https://bakapiano.github.io/cssm/v1/ and click "Start ccsm" on the offline banner to launch the backend.');
108
107
  } catch (e) {
109
108
  warn(`failed · ${e.message}`);
110
109
  warn('the hosted frontend\'s "Start ccsm" button will not be able to launch the backend. You can still run `ccsm` manually in a terminal.');
111
110
  }
111
+
112
+ // Auto-launch ccsm after install so the user lands directly in the app
113
+ // without needing a second command. Detached + windowsHide so the npm
114
+ // install command returns immediately. Skip if CCSM_NO_AUTOLAUNCH=1 is
115
+ // set (CI, headless setups).
116
+ if (process.env.CCSM_NO_AUTOLAUNCH !== '1') {
117
+ try {
118
+ const { spawn } = require('node:child_process');
119
+ const child = spawn(ccsmCmd, [], {
120
+ detached: true,
121
+ stdio: 'ignore',
122
+ windowsHide: true,
123
+ shell: false,
124
+ });
125
+ child.unref();
126
+ log('launching ccsm now · check for the chromeless window');
127
+ log('(set CCSM_NO_AUTOLAUNCH=1 to skip this on future installs)');
128
+ } catch (e) {
129
+ warn(`auto-launch failed · ${e.message}`);
130
+ warn('run `ccsm` manually to start.');
131
+ }
132
+ }