@bakapiano/ccsm 0.10.3 → 0.11.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.
Files changed (48) hide show
  1. package/CLAUDE.md +475 -475
  2. package/README.md +190 -190
  3. package/bin/ccsm.js +194 -194
  4. package/lib/cliSessionWatcher.js +249 -249
  5. package/lib/config.js +185 -185
  6. package/lib/folders.js +96 -96
  7. package/lib/localCliSessions.js +489 -177
  8. package/lib/persistedSessions.js +134 -134
  9. package/lib/webTerminal.js +208 -208
  10. package/lib/workspace.js +230 -255
  11. package/package.json +57 -57
  12. package/public/css/base.css +99 -99
  13. package/public/css/cards.css +183 -183
  14. package/public/css/feedback.css +303 -303
  15. package/public/css/forms.css +405 -405
  16. package/public/css/layout.css +160 -160
  17. package/public/css/modal.css +190 -183
  18. package/public/css/responsive.css +10 -10
  19. package/public/css/sidebar.css +616 -601
  20. package/public/css/terminals.css +294 -294
  21. package/public/css/tokens.css +81 -79
  22. package/public/css/wco.css +98 -98
  23. package/public/css/widgets.css +1596 -1375
  24. package/public/index.html +105 -103
  25. package/public/js/api.js +272 -260
  26. package/public/js/components/AdoptModal.js +343 -171
  27. package/public/js/components/App.js +35 -35
  28. package/public/js/components/DirectoryPicker.js +203 -203
  29. package/public/js/components/EntityFormModal.js +105 -105
  30. package/public/js/components/Modal.js +51 -51
  31. package/public/js/components/OfflineBanner.js +93 -93
  32. package/public/js/components/PageTitleBar.js +13 -13
  33. package/public/js/components/Picker.js +179 -179
  34. package/public/js/components/Popover.js +55 -55
  35. package/public/js/components/Sidebar.js +270 -270
  36. package/public/js/components/TerminalView.js +298 -298
  37. package/public/js/components/useDragSort.js +67 -67
  38. package/public/js/dialog.js +67 -67
  39. package/public/js/icons.js +177 -177
  40. package/public/js/main.js +140 -140
  41. package/public/js/pages/AboutPage.js +165 -165
  42. package/public/js/pages/ConfigurePage.js +475 -487
  43. package/public/js/pages/LaunchPage.js +369 -369
  44. package/public/js/pages/SessionsPage.js +97 -97
  45. package/public/js/state.js +231 -231
  46. package/public/manifest.webmanifest +15 -15
  47. package/scripts/install.js +137 -137
  48. package/server.js +1126 -1117
@@ -1,231 +1,231 @@
1
- // All shared reactive state. Importing a signal anywhere subscribes the
2
- // reading component, so we never need a store / context wrapper.
3
-
4
- import { signal, computed } from '@preact/signals';
5
-
6
- // ── server-driven data ──────────────────────────────────────────
7
- export const config = signal(null);
8
- export const capabilities = signal({ webTerminal: false });
9
- // `sessions` is the ccsm-persisted list (lib/persistedSessions). Every
10
- // entry has { id, cliId, cwd, workspace, title, folderId, repos,
11
- // createdAt, lastActiveAt, status, exitedAt, exitCode, pid }.
12
- export const sessions = signal([]);
13
- export const folders = signal([]); // [{id,name,order,createdAt}]
14
- export const workspaces = signal([]);
15
- export const serverHealth = signal({ state: 'connecting' });
16
-
17
- // ── ui state (persisted in localStorage where noted) ───────────
18
- export const activeTab = signal('sessions');
19
- export const activeSessionId = signal(null); // the session currently rendered in the right pane
20
- export const sidebarCollapsed = signal(false);
21
- // True when viewport is narrow enough that the sidebar is force-collapsed
22
- // by the responsive layout — the toggle button hides in that case so the
23
- // user can't try (and fail) to expand it.
24
- export const sidebarForcedCollapsed = signal(false);
25
- export const sidebarWidth = signal(232); // px when expanded, persisted in localStorage
26
- export const accentColor = signal('#2f6fa3'); // user-chosen brand accent, persisted
27
- // Per-folder collapse state in the sidebar tree. Stored as a plain object
28
- // {folderId: true} (true = collapsed). Key 'unsorted' covers the implicit
29
- // Unsorted bucket.
30
- export const foldersCollapsed = signal({});
31
- export const configDirty = signal(false);
32
- // Per-card fold state on pages that use the <Card> component. The card
33
- // just toggles a key here; persistence is best-effort via localStorage
34
- // under `ccsm.fold.<key>` (set by toggleCardFold).
35
- export const cardFolded = signal({});
36
- export const clockTick = signal(Date.now()); // re-ticked each second so fmtAgo refreshes
37
- export const lastRefreshAt = signal(0); // ms timestamp of last successful refreshAll()
38
- export const installPrompt = signal(null); // captured beforeinstallprompt event (PWA install)
39
- export const isInstalledPwa = signal(false); // running inside an installed PWA window
40
-
41
- // ── derived ─────────────────────────────────────────────────────
42
- // Group sessions by folder, with a synthetic "unsorted" bucket for those
43
- // without a folderId. Folders define the rendering order; sessions
44
- // inside each are sorted by createdAt desc (stable — using lastActiveAt
45
- // would make rows jump on resume).
46
- //
47
- // We pre-create a bucket per declared session.folderId even if the
48
- // matching folder hasn't loaded yet — that way on first paint sessions
49
- // don't all collapse into Unsorted and then snap back into their real
50
- // folder a few ms later when /api/folders resolves.
51
- export const sessionsByFolder = computed(() => {
52
- const groups = new Map();
53
- groups.set(null, []);
54
- for (const f of folders.value) groups.set(f.id, []);
55
- for (const s of sessions.value) {
56
- const key = s.folderId || null;
57
- if (!groups.has(key)) groups.set(key, []);
58
- groups.get(key).push(s);
59
- }
60
- for (const list of groups.values()) {
61
- list.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
62
- }
63
- return groups;
64
- });
65
-
66
- export const TAB_HEADINGS = {
67
- sessions: { title: 'Sessions', subtitle: 'Sessions you started in ccsm.' },
68
- launch: { title: 'Launch', subtitle: 'Spin up a new session in a fresh workspace.' },
69
- configure: { title: 'Configure', subtitle: 'Persisted to ~/.ccsm/config.json.' },
70
- about: { title: 'About', subtitle: 'ccsm — Claude CLI Sessions Manager.' },
71
- };
72
-
73
- // ── persistence helpers (localStorage) ──────────────────────────
74
- const LS_SIDEBAR = 'ccsm.sidebar-collapsed';
75
- const LS_SIDEBAR_W = 'ccsm.sidebar-width';
76
- const LS_ACCENT = 'ccsm.accent';
77
- const LS_FOLDERS_COLLAPSED = 'ccsm.folders-collapsed';
78
- // Last-known sidebar tree, rehydrated on boot to keep the first paint
79
- // stable. The next refreshAll() overwrites these from the server, so
80
- // stale entries self-heal within ~5s without any explicit invalidation.
81
- const LS_FOLDERS_CACHE = 'ccsm.folders-cache';
82
- const LS_SESSIONS_CACHE = 'ccsm.sessions-cache';
83
-
84
- export const SIDEBAR_MIN = 180;
85
- export const SIDEBAR_MAX = 400;
86
- export const SIDEBAR_DEFAULT = 232;
87
- export const ACCENT_DEFAULT = '#2f6fa3';
88
-
89
- export function loadPersisted() {
90
- sidebarCollapsed.value = localStorage.getItem(LS_SIDEBAR) === 'true';
91
- const w = Number(localStorage.getItem(LS_SIDEBAR_W));
92
- if (Number.isFinite(w) && w >= SIDEBAR_MIN && w <= SIDEBAR_MAX) {
93
- sidebarWidth.value = w;
94
- }
95
- applySidebarWidthCssVar();
96
- const a = localStorage.getItem(LS_ACCENT);
97
- if (isHexColor(a)) accentColor.value = a;
98
- applyAccentCssVars();
99
- try {
100
- const raw = localStorage.getItem(LS_FOLDERS_COLLAPSED);
101
- if (raw) {
102
- const parsed = JSON.parse(raw);
103
- if (parsed && typeof parsed === 'object') foldersCollapsed.value = parsed;
104
- }
105
- } catch {}
106
- // Rehydrate the sidebar tree from the last seen server state so
107
- // the first paint matches the user's last view. refreshAll() arrives
108
- // ~50–500ms later and overwrites with fresh data.
109
- try {
110
- const raw = localStorage.getItem(LS_FOLDERS_CACHE);
111
- if (raw) {
112
- const parsed = JSON.parse(raw);
113
- if (Array.isArray(parsed)) folders.value = parsed;
114
- }
115
- } catch {}
116
- try {
117
- const raw = localStorage.getItem(LS_SESSIONS_CACHE);
118
- if (raw) {
119
- const parsed = JSON.parse(raw);
120
- if (Array.isArray(parsed)) sessions.value = parsed;
121
- }
122
- } catch {}
123
- const hash = location.hash.slice(1);
124
- if (TAB_HEADINGS[hash]) activeTab.value = hash;
125
- }
126
-
127
- function applySidebarWidthCssVar() {
128
- document.documentElement.style.setProperty('--sidebar-w', `${sidebarWidth.value}px`);
129
- }
130
-
131
- export function setSidebarWidth(px) {
132
- const clamped = Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, Math.round(px)));
133
- sidebarWidth.value = clamped;
134
- applySidebarWidthCssVar();
135
- localStorage.setItem(LS_SIDEBAR_W, String(clamped));
136
- }
137
-
138
- // ── theme accent ────────────────────────────────────────────────
139
- function isHexColor(s) {
140
- return typeof s === 'string' && /^#[0-9a-fA-F]{6}$/.test(s);
141
- }
142
- function hexToRgb(hex) {
143
- const n = parseInt(hex.slice(1), 16);
144
- return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
145
- }
146
- function rgbToHex({ r, g, b }) {
147
- const h = (n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0');
148
- return `#${h(r)}${h(g)}${h(b)}`;
149
- }
150
- function darken({ r, g, b }, amount) {
151
- return { r: r * (1 - amount), g: g * (1 - amount), b: b * (1 - amount) };
152
- }
153
- function mixWithWhite({ r, g, b }, t) {
154
- return { r: r * t + 255 * (1 - t), g: g * t + 255 * (1 - t), b: b * t + 255 * (1 - t) };
155
- }
156
- function applyAccentCssVars() {
157
- const base = accentColor.value;
158
- const rgb = hexToRgb(base);
159
- const deep = rgbToHex(darken(rgb, 0.2));
160
- const soft = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.10)`;
161
- const softer = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.04)`;
162
- const bg = rgbToHex(mixWithWhite(rgb, 0.04));
163
- const sidebarHover = rgbToHex(mixWithWhite(rgb, 0.10));
164
- const sidebarActive= rgbToHex(mixWithWhite(rgb, 0.15));
165
- const border = rgbToHex(mixWithWhite(rgb, 0.15));
166
- const borderSoft = rgbToHex(mixWithWhite(rgb, 0.12));
167
- const borderStrong = rgbToHex(mixWithWhite(rgb, 0.25));
168
- // UI chrome (sidebar bg, dividers, footer strip) — themed too but
169
- // visibly darker than the main bg so sidebar/main read as distinct.
170
- // Border colors stay deliberately desaturated so dividers don't shout
171
- // the brand color back at the user.
172
- const uiBg = rgbToHex(mixWithWhite(rgb, 0.10));
173
- const uiBorder = '#d8d4c6'; // theme-independent neutral
174
- const uiBorderSoft = '#e6e2d4'; // theme-independent neutral
175
- const root = document.documentElement.style;
176
- root.setProperty('--accent', base);
177
- root.setProperty('--accent-deep', deep);
178
- root.setProperty('--accent-soft', soft);
179
- root.setProperty('--accent-softer', softer);
180
- root.setProperty('--bg', bg);
181
- root.setProperty('--sidebar-bg', bg);
182
- root.setProperty('--sidebar-hover', sidebarHover);
183
- root.setProperty('--sidebar-active', sidebarActive);
184
- root.setProperty('--border', border);
185
- root.setProperty('--border-soft', borderSoft);
186
- root.setProperty('--border-strong', borderStrong);
187
- root.setProperty('--ui-bg', uiBg);
188
- root.setProperty('--ui-border', uiBorder);
189
- root.setProperty('--ui-border-soft', uiBorderSoft);
190
- const meta = document.querySelector('meta[name="theme-color"]');
191
- if (meta) meta.setAttribute('content', bg);
192
- }
193
-
194
- export function setAccentColor(hex) {
195
- if (!isHexColor(hex)) return;
196
- accentColor.value = hex;
197
- applyAccentCssVars();
198
- localStorage.setItem(LS_ACCENT, hex);
199
- }
200
-
201
- // ── actions ─────────────────────────────────────────────────────
202
- export function selectTab(name) {
203
- if (!TAB_HEADINGS[name]) name = 'sessions';
204
- activeTab.value = name;
205
- if (location.hash !== `#${name}`) window.history.replaceState(null, '', `#${name}`);
206
- }
207
-
208
- export function selectSession(id) {
209
- activeSessionId.value = id;
210
- activeTab.value = 'sessions';
211
- if (location.hash !== '#sessions') window.history.replaceState(null, '', '#sessions');
212
- }
213
-
214
- export function toggleSidebar() {
215
- if (sidebarForcedCollapsed.value) return;
216
- sidebarCollapsed.value = !sidebarCollapsed.value;
217
- localStorage.setItem(LS_SIDEBAR, String(sidebarCollapsed.value));
218
- }
219
-
220
- export function toggleFolder(folderId) {
221
- const key = folderId || 'unsorted';
222
- const next = { ...foldersCollapsed.value, [key]: !foldersCollapsed.value[key] };
223
- foldersCollapsed.value = next;
224
- localStorage.setItem(LS_FOLDERS_COLLAPSED, JSON.stringify(next));
225
- }
226
-
227
- export function toggleCardFold(key) {
228
- const next = { ...cardFolded.value, [key]: !cardFolded.value[key] };
229
- cardFolded.value = next;
230
- try { localStorage.setItem(`ccsm.fold.${key}`, next[key] ? '1' : '0'); } catch {}
231
- }
1
+ // All shared reactive state. Importing a signal anywhere subscribes the
2
+ // reading component, so we never need a store / context wrapper.
3
+
4
+ import { signal, computed } from '@preact/signals';
5
+
6
+ // ── server-driven data ──────────────────────────────────────────
7
+ export const config = signal(null);
8
+ export const capabilities = signal({ webTerminal: false });
9
+ // `sessions` is the ccsm-persisted list (lib/persistedSessions). Every
10
+ // entry has { id, cliId, cwd, workspace, title, folderId, repos,
11
+ // createdAt, lastActiveAt, status, exitedAt, exitCode, pid }.
12
+ export const sessions = signal([]);
13
+ export const folders = signal([]); // [{id,name,order,createdAt}]
14
+ export const workspaces = signal([]);
15
+ export const serverHealth = signal({ state: 'connecting' });
16
+
17
+ // ── ui state (persisted in localStorage where noted) ───────────
18
+ export const activeTab = signal('sessions');
19
+ export const activeSessionId = signal(null); // the session currently rendered in the right pane
20
+ export const sidebarCollapsed = signal(false);
21
+ // True when viewport is narrow enough that the sidebar is force-collapsed
22
+ // by the responsive layout — the toggle button hides in that case so the
23
+ // user can't try (and fail) to expand it.
24
+ export const sidebarForcedCollapsed = signal(false);
25
+ export const sidebarWidth = signal(232); // px when expanded, persisted in localStorage
26
+ export const accentColor = signal('#2f6fa3'); // user-chosen brand accent, persisted
27
+ // Per-folder collapse state in the sidebar tree. Stored as a plain object
28
+ // {folderId: true} (true = collapsed). Key 'unsorted' covers the implicit
29
+ // Unsorted bucket.
30
+ export const foldersCollapsed = signal({});
31
+ export const configDirty = signal(false);
32
+ // Per-card fold state on pages that use the <Card> component. The card
33
+ // just toggles a key here; persistence is best-effort via localStorage
34
+ // under `ccsm.fold.<key>` (set by toggleCardFold).
35
+ export const cardFolded = signal({});
36
+ export const clockTick = signal(Date.now()); // re-ticked each second so fmtAgo refreshes
37
+ export const lastRefreshAt = signal(0); // ms timestamp of last successful refreshAll()
38
+ export const installPrompt = signal(null); // captured beforeinstallprompt event (PWA install)
39
+ export const isInstalledPwa = signal(false); // running inside an installed PWA window
40
+
41
+ // ── derived ─────────────────────────────────────────────────────
42
+ // Group sessions by folder, with a synthetic "unsorted" bucket for those
43
+ // without a folderId. Folders define the rendering order; sessions
44
+ // inside each are sorted by createdAt desc (stable — using lastActiveAt
45
+ // would make rows jump on resume).
46
+ //
47
+ // We pre-create a bucket per declared session.folderId even if the
48
+ // matching folder hasn't loaded yet — that way on first paint sessions
49
+ // don't all collapse into Unsorted and then snap back into their real
50
+ // folder a few ms later when /api/folders resolves.
51
+ export const sessionsByFolder = computed(() => {
52
+ const groups = new Map();
53
+ groups.set(null, []);
54
+ for (const f of folders.value) groups.set(f.id, []);
55
+ for (const s of sessions.value) {
56
+ const key = s.folderId || null;
57
+ if (!groups.has(key)) groups.set(key, []);
58
+ groups.get(key).push(s);
59
+ }
60
+ for (const list of groups.values()) {
61
+ list.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
62
+ }
63
+ return groups;
64
+ });
65
+
66
+ export const TAB_HEADINGS = {
67
+ sessions: { title: 'Sessions', subtitle: 'Sessions you started in ccsm.' },
68
+ launch: { title: 'Launch', subtitle: 'Spin up a new session in a fresh workspace.' },
69
+ configure: { title: 'Configure', subtitle: 'Persisted to ~/.ccsm/config.json.' },
70
+ about: { title: 'About', subtitle: 'ccsm — Claude CLI Sessions Manager.' },
71
+ };
72
+
73
+ // ── persistence helpers (localStorage) ──────────────────────────
74
+ const LS_SIDEBAR = 'ccsm.sidebar-collapsed';
75
+ const LS_SIDEBAR_W = 'ccsm.sidebar-width';
76
+ const LS_ACCENT = 'ccsm.accent';
77
+ const LS_FOLDERS_COLLAPSED = 'ccsm.folders-collapsed';
78
+ // Last-known sidebar tree, rehydrated on boot to keep the first paint
79
+ // stable. The next refreshAll() overwrites these from the server, so
80
+ // stale entries self-heal within ~5s without any explicit invalidation.
81
+ const LS_FOLDERS_CACHE = 'ccsm.folders-cache';
82
+ const LS_SESSIONS_CACHE = 'ccsm.sessions-cache';
83
+
84
+ export const SIDEBAR_MIN = 180;
85
+ export const SIDEBAR_MAX = 400;
86
+ export const SIDEBAR_DEFAULT = 232;
87
+ export const ACCENT_DEFAULT = '#2f6fa3';
88
+
89
+ export function loadPersisted() {
90
+ sidebarCollapsed.value = localStorage.getItem(LS_SIDEBAR) === 'true';
91
+ const w = Number(localStorage.getItem(LS_SIDEBAR_W));
92
+ if (Number.isFinite(w) && w >= SIDEBAR_MIN && w <= SIDEBAR_MAX) {
93
+ sidebarWidth.value = w;
94
+ }
95
+ applySidebarWidthCssVar();
96
+ const a = localStorage.getItem(LS_ACCENT);
97
+ if (isHexColor(a)) accentColor.value = a;
98
+ applyAccentCssVars();
99
+ try {
100
+ const raw = localStorage.getItem(LS_FOLDERS_COLLAPSED);
101
+ if (raw) {
102
+ const parsed = JSON.parse(raw);
103
+ if (parsed && typeof parsed === 'object') foldersCollapsed.value = parsed;
104
+ }
105
+ } catch {}
106
+ // Rehydrate the sidebar tree from the last seen server state so
107
+ // the first paint matches the user's last view. refreshAll() arrives
108
+ // ~50–500ms later and overwrites with fresh data.
109
+ try {
110
+ const raw = localStorage.getItem(LS_FOLDERS_CACHE);
111
+ if (raw) {
112
+ const parsed = JSON.parse(raw);
113
+ if (Array.isArray(parsed)) folders.value = parsed;
114
+ }
115
+ } catch {}
116
+ try {
117
+ const raw = localStorage.getItem(LS_SESSIONS_CACHE);
118
+ if (raw) {
119
+ const parsed = JSON.parse(raw);
120
+ if (Array.isArray(parsed)) sessions.value = parsed;
121
+ }
122
+ } catch {}
123
+ const hash = location.hash.slice(1);
124
+ if (TAB_HEADINGS[hash]) activeTab.value = hash;
125
+ }
126
+
127
+ function applySidebarWidthCssVar() {
128
+ document.documentElement.style.setProperty('--sidebar-w', `${sidebarWidth.value}px`);
129
+ }
130
+
131
+ export function setSidebarWidth(px) {
132
+ const clamped = Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, Math.round(px)));
133
+ sidebarWidth.value = clamped;
134
+ applySidebarWidthCssVar();
135
+ localStorage.setItem(LS_SIDEBAR_W, String(clamped));
136
+ }
137
+
138
+ // ── theme accent ────────────────────────────────────────────────
139
+ function isHexColor(s) {
140
+ return typeof s === 'string' && /^#[0-9a-fA-F]{6}$/.test(s);
141
+ }
142
+ function hexToRgb(hex) {
143
+ const n = parseInt(hex.slice(1), 16);
144
+ return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
145
+ }
146
+ function rgbToHex({ r, g, b }) {
147
+ const h = (n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0');
148
+ return `#${h(r)}${h(g)}${h(b)}`;
149
+ }
150
+ function darken({ r, g, b }, amount) {
151
+ return { r: r * (1 - amount), g: g * (1 - amount), b: b * (1 - amount) };
152
+ }
153
+ function mixWithWhite({ r, g, b }, t) {
154
+ return { r: r * t + 255 * (1 - t), g: g * t + 255 * (1 - t), b: b * t + 255 * (1 - t) };
155
+ }
156
+ function applyAccentCssVars() {
157
+ const base = accentColor.value;
158
+ const rgb = hexToRgb(base);
159
+ const deep = rgbToHex(darken(rgb, 0.2));
160
+ const soft = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.10)`;
161
+ const softer = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.04)`;
162
+ const bg = rgbToHex(mixWithWhite(rgb, 0.04));
163
+ const sidebarHover = rgbToHex(mixWithWhite(rgb, 0.10));
164
+ const sidebarActive= rgbToHex(mixWithWhite(rgb, 0.15));
165
+ const border = rgbToHex(mixWithWhite(rgb, 0.15));
166
+ const borderSoft = rgbToHex(mixWithWhite(rgb, 0.12));
167
+ const borderStrong = rgbToHex(mixWithWhite(rgb, 0.25));
168
+ // UI chrome (sidebar bg, dividers, footer strip) — themed too but
169
+ // visibly darker than the main bg so sidebar/main read as distinct.
170
+ // Border colors stay deliberately desaturated so dividers don't shout
171
+ // the brand color back at the user.
172
+ const uiBg = rgbToHex(mixWithWhite(rgb, 0.10));
173
+ const uiBorder = '#d8d4c6'; // theme-independent neutral
174
+ const uiBorderSoft = '#e6e2d4'; // theme-independent neutral
175
+ const root = document.documentElement.style;
176
+ root.setProperty('--accent', base);
177
+ root.setProperty('--accent-deep', deep);
178
+ root.setProperty('--accent-soft', soft);
179
+ root.setProperty('--accent-softer', softer);
180
+ root.setProperty('--bg', bg);
181
+ root.setProperty('--sidebar-bg', bg);
182
+ root.setProperty('--sidebar-hover', sidebarHover);
183
+ root.setProperty('--sidebar-active', sidebarActive);
184
+ root.setProperty('--border', border);
185
+ root.setProperty('--border-soft', borderSoft);
186
+ root.setProperty('--border-strong', borderStrong);
187
+ root.setProperty('--ui-bg', uiBg);
188
+ root.setProperty('--ui-border', uiBorder);
189
+ root.setProperty('--ui-border-soft', uiBorderSoft);
190
+ const meta = document.querySelector('meta[name="theme-color"]');
191
+ if (meta) meta.setAttribute('content', bg);
192
+ }
193
+
194
+ export function setAccentColor(hex) {
195
+ if (!isHexColor(hex)) return;
196
+ accentColor.value = hex;
197
+ applyAccentCssVars();
198
+ localStorage.setItem(LS_ACCENT, hex);
199
+ }
200
+
201
+ // ── actions ─────────────────────────────────────────────────────
202
+ export function selectTab(name) {
203
+ if (!TAB_HEADINGS[name]) name = 'sessions';
204
+ activeTab.value = name;
205
+ if (location.hash !== `#${name}`) window.history.replaceState(null, '', `#${name}`);
206
+ }
207
+
208
+ export function selectSession(id) {
209
+ activeSessionId.value = id;
210
+ activeTab.value = 'sessions';
211
+ if (location.hash !== '#sessions') window.history.replaceState(null, '', '#sessions');
212
+ }
213
+
214
+ export function toggleSidebar() {
215
+ if (sidebarForcedCollapsed.value) return;
216
+ sidebarCollapsed.value = !sidebarCollapsed.value;
217
+ localStorage.setItem(LS_SIDEBAR, String(sidebarCollapsed.value));
218
+ }
219
+
220
+ export function toggleFolder(folderId) {
221
+ const key = folderId || 'unsorted';
222
+ const next = { ...foldersCollapsed.value, [key]: !foldersCollapsed.value[key] };
223
+ foldersCollapsed.value = next;
224
+ localStorage.setItem(LS_FOLDERS_COLLAPSED, JSON.stringify(next));
225
+ }
226
+
227
+ export function toggleCardFold(key) {
228
+ const next = { ...cardFolded.value, [key]: !cardFolded.value[key] };
229
+ cardFolded.value = next;
230
+ try { localStorage.setItem(`ccsm.fold.${key}`, next[key] ? '1' : '0'); } catch {}
231
+ }
@@ -1,15 +1,15 @@
1
- {
2
- "name": "CCSM",
3
- "short_name": "CCSM",
4
- "version": "0.0.0-dev",
5
- "description": "Single pane over every live claude session on this machine.",
6
- "start_url": "./",
7
- "scope": "./",
8
- "display": "standalone",
9
- "display_override": ["window-controls-overlay", "standalone"],
10
- "background_color": "#faf9f5",
11
- "theme_color": "#faf9f5",
12
- "icons": [
13
- { "src": "favicon.svg", "type": "image/svg+xml", "sizes": "any", "purpose": "any" }
14
- ]
15
- }
1
+ {
2
+ "name": "CCSM",
3
+ "short_name": "CCSM",
4
+ "version": "0.0.0-dev",
5
+ "description": "Single pane over every live claude session on this machine.",
6
+ "start_url": "./",
7
+ "scope": "./",
8
+ "display": "standalone",
9
+ "display_override": ["window-controls-overlay", "standalone"],
10
+ "background_color": "#f6f8fa",
11
+ "theme_color": "#f6f8fa",
12
+ "icons": [
13
+ { "src": "favicon.svg", "type": "image/svg+xml", "sizes": "any", "purpose": "any" }
14
+ ]
15
+ }