@bakapiano/ccsm 0.6.0 → 0.8.4
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/CLAUDE.md +377 -123
- package/README.md +172 -38
- package/bin/ccsm.js +194 -0
- package/lib/favorites.js +23 -45
- package/lib/jsonStore.js +60 -0
- package/lib/labels.js +21 -41
- package/lib/webTerminal.js +173 -0
- package/package.json +11 -3
- package/public/css/base.css +82 -0
- package/public/css/cards.css +149 -0
- package/public/css/feedback.css +219 -0
- package/public/css/forms.css +282 -0
- package/public/css/layout.css +107 -0
- package/public/css/modal.css +169 -0
- package/public/css/responsive.css +10 -0
- package/public/css/sidebar.css +165 -0
- package/public/css/tables.css +266 -0
- package/public/css/terminals.css +112 -0
- package/public/css/tokens.css +63 -0
- package/public/css/wco.css +70 -0
- package/public/css/widgets.css +204 -0
- package/public/favicon.svg +1 -1
- package/public/index.html +52 -490
- package/public/js/actions.js +87 -0
- package/public/js/api.js +103 -0
- package/public/js/backend.js +28 -0
- package/public/js/components/App.js +45 -0
- package/public/js/components/Card.js +24 -0
- package/public/js/components/DialogHost.js +45 -0
- package/public/js/components/Fab.js +11 -0
- package/public/js/components/FavoritesTable.js +81 -0
- package/public/js/components/Footer.js +12 -0
- package/public/js/components/NewSessionModal.js +142 -0
- package/public/js/components/OfflineBanner.js +52 -0
- package/public/js/components/PageHead.js +33 -0
- package/public/js/components/Pagination.js +27 -0
- package/public/js/components/ProgressList.js +32 -0
- package/public/js/components/RecentTable.js +68 -0
- package/public/js/components/RepoPicker.js +40 -0
- package/public/js/components/ReposEditor.js +74 -0
- package/public/js/components/ServerStatus.js +18 -0
- package/public/js/components/SessionsTable.js +71 -0
- package/public/js/components/Sidebar.js +52 -0
- package/public/js/components/SnapshotPanel.js +77 -0
- package/public/js/components/TerminalView.js +108 -0
- package/public/js/components/TitleCell.js +40 -0
- package/public/js/components/Toast.js +8 -0
- package/public/js/components/WorkspacePicker.js +19 -0
- package/public/js/components/WorkspacesGrid.js +41 -0
- package/public/js/dialog.js +59 -0
- package/public/js/html.js +6 -0
- package/public/js/icons.js +114 -0
- package/public/js/main.js +81 -0
- package/public/js/pages/AboutPage.js +85 -0
- package/public/js/pages/ConfigurePage.js +194 -0
- package/public/js/pages/LaunchPage.js +117 -0
- package/public/js/pages/SessionsPage.js +47 -0
- package/public/js/pages/TerminalsPage.js +74 -0
- package/public/js/state.js +87 -0
- package/public/js/streaming.js +96 -0
- package/public/js/toast.js +14 -0
- package/public/js/util.js +24 -0
- package/public/manifest.webmanifest +14 -0
- package/scripts/install.js +132 -0
- package/scripts/uninstall.js +56 -0
- package/server.js +286 -30
- package/public/app.js +0 -1353
- package/public/styles.css +0 -1639
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ccsm in-process PTY pool. Used by the "web terminal" launch path:
|
|
4
|
+
// claude (or any cmd) runs as a child of ccsm and its stdio is bridged
|
|
5
|
+
// to one or more WebSocket clients via xterm.js in the browser.
|
|
6
|
+
//
|
|
7
|
+
// Lifecycle: a PTY entry is created by spawn(), broadcasts every output
|
|
8
|
+
// chunk to all attached sockets, keeps a rolling history ring so a fresh
|
|
9
|
+
// connection can replay recent output. attach() wires a websocket to an
|
|
10
|
+
// entry, kill() ends a PTY explicitly, list() returns metadata for UI.
|
|
11
|
+
//
|
|
12
|
+
// node-pty is optional (Windows native binary). If it failed to load,
|
|
13
|
+
// `available` is false and spawn() throws — server.js gates the
|
|
14
|
+
// /api/sessions/web route on this flag so install failures degrade
|
|
15
|
+
// gracefully to wt-only mode.
|
|
16
|
+
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
|
|
19
|
+
let pty = null;
|
|
20
|
+
let loadError = null;
|
|
21
|
+
try {
|
|
22
|
+
pty = require('node-pty');
|
|
23
|
+
} catch (e) {
|
|
24
|
+
loadError = e;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const HISTORY_BYTES = 256 * 1024;
|
|
28
|
+
|
|
29
|
+
// Map<id, { id, pty, history, sockets:Set<ws>, meta }>
|
|
30
|
+
const sessions = new Map();
|
|
31
|
+
|
|
32
|
+
function genId() {
|
|
33
|
+
return 'web-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Spawn a new PTY. `command` and `args` are passed straight to node-pty.
|
|
37
|
+
// `meta` is whatever the caller wants surfaced to the UI (title, cwd, etc).
|
|
38
|
+
// Throws if node-pty isn't available.
|
|
39
|
+
function spawn({ command, args = [], cwd, env, cols = 120, rows = 30, meta = {} }) {
|
|
40
|
+
if (!pty) {
|
|
41
|
+
const err = new Error('node-pty is not available · ' + (loadError && loadError.message || 'unknown'));
|
|
42
|
+
err.code = 'PTY_UNAVAILABLE';
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
45
|
+
const id = genId();
|
|
46
|
+
const proc = pty.spawn(command, args, {
|
|
47
|
+
name: 'xterm-256color',
|
|
48
|
+
cols, rows,
|
|
49
|
+
cwd: cwd ? path.resolve(cwd) : process.cwd(),
|
|
50
|
+
env: { ...process.env, ...(env || {}) },
|
|
51
|
+
});
|
|
52
|
+
const entry = {
|
|
53
|
+
id,
|
|
54
|
+
pty: proc,
|
|
55
|
+
history: '',
|
|
56
|
+
sockets: new Set(),
|
|
57
|
+
meta: { ...meta, startedAt: Date.now(), command, args, cwd: cwd || process.cwd(), pid: proc.pid },
|
|
58
|
+
exitCode: null,
|
|
59
|
+
exitedAt: null,
|
|
60
|
+
};
|
|
61
|
+
proc.onData((data) => {
|
|
62
|
+
// Append to ring; truncate to last HISTORY_BYTES so memory stays bounded.
|
|
63
|
+
entry.history = (entry.history + data);
|
|
64
|
+
if (entry.history.length > HISTORY_BYTES) {
|
|
65
|
+
entry.history = entry.history.slice(-HISTORY_BYTES);
|
|
66
|
+
}
|
|
67
|
+
const frame = JSON.stringify({ type: 'output', data });
|
|
68
|
+
for (const ws of entry.sockets) {
|
|
69
|
+
try { ws.send(frame); } catch {}
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
proc.onExit(({ exitCode, signal }) => {
|
|
73
|
+
entry.exitCode = exitCode;
|
|
74
|
+
entry.exitedAt = Date.now();
|
|
75
|
+
const frame = JSON.stringify({ type: 'exit', code: exitCode, signal });
|
|
76
|
+
for (const ws of entry.sockets) {
|
|
77
|
+
try { ws.send(frame); } catch {}
|
|
78
|
+
}
|
|
79
|
+
// Keep the entry around briefly so a reconnecting client can see the
|
|
80
|
+
// exit code + final transcript, then drop it. 30s is enough for a UI
|
|
81
|
+
// re-render but won't hoard memory forever.
|
|
82
|
+
setTimeout(() => sessions.delete(id), 30_000);
|
|
83
|
+
});
|
|
84
|
+
sessions.set(id, entry);
|
|
85
|
+
return entry;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Wire a websocket to a session. Replays history immediately so the
|
|
89
|
+
// client sees recent context; then forwards input/resize messages from
|
|
90
|
+
// the client to the PTY and broadcast outputs back via onData above.
|
|
91
|
+
function attach(id, ws) {
|
|
92
|
+
const entry = sessions.get(id);
|
|
93
|
+
if (!entry) {
|
|
94
|
+
try { ws.close(4404, 'no such terminal'); } catch {}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
entry.sockets.add(ws);
|
|
98
|
+
if (entry.history) {
|
|
99
|
+
try { ws.send(JSON.stringify({ type: 'output', data: entry.history })); } catch {}
|
|
100
|
+
}
|
|
101
|
+
if (entry.exitedAt) {
|
|
102
|
+
try { ws.send(JSON.stringify({ type: 'exit', code: entry.exitCode })); } catch {}
|
|
103
|
+
} else {
|
|
104
|
+
try { ws.send(JSON.stringify({ type: 'attached', meta: entry.meta })); } catch {}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
ws.on('message', (msg) => {
|
|
108
|
+
let event;
|
|
109
|
+
try { event = JSON.parse(msg.toString()); } catch { return; }
|
|
110
|
+
if (entry.exitedAt) return; // PTY is dead, ignore further input
|
|
111
|
+
switch (event.type) {
|
|
112
|
+
case 'input':
|
|
113
|
+
if (typeof event.data === 'string') entry.pty.write(event.data);
|
|
114
|
+
break;
|
|
115
|
+
case 'resize':
|
|
116
|
+
if (Number(event.cols) > 0 && Number(event.rows) > 0) {
|
|
117
|
+
try { entry.pty.resize(Number(event.cols), Number(event.rows)); } catch {}
|
|
118
|
+
}
|
|
119
|
+
break;
|
|
120
|
+
case 'kill':
|
|
121
|
+
kill(id);
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
ws.on('close', () => {
|
|
127
|
+
entry.sockets.delete(ws);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function kill(id) {
|
|
132
|
+
const entry = sessions.get(id);
|
|
133
|
+
if (!entry || entry.exitedAt) return false;
|
|
134
|
+
try { entry.pty.kill(); } catch {}
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Public summary for the frontend. Don't leak the pty / sockets objects.
|
|
139
|
+
function describe(entry) {
|
|
140
|
+
return {
|
|
141
|
+
id: entry.id,
|
|
142
|
+
meta: entry.meta,
|
|
143
|
+
attached: entry.sockets.size,
|
|
144
|
+
exitedAt: entry.exitedAt,
|
|
145
|
+
exitCode: entry.exitCode,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function list() {
|
|
150
|
+
return Array.from(sessions.values()).map(describe);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function get(id) {
|
|
154
|
+
const e = sessions.get(id);
|
|
155
|
+
return e ? describe(e) : null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function killAll() {
|
|
159
|
+
for (const e of sessions.values()) {
|
|
160
|
+
try { e.pty.kill(); } catch {}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = {
|
|
165
|
+
available: !!pty,
|
|
166
|
+
loadError,
|
|
167
|
+
spawn,
|
|
168
|
+
attach,
|
|
169
|
+
kill,
|
|
170
|
+
list,
|
|
171
|
+
get,
|
|
172
|
+
killAll,
|
|
173
|
+
};
|
package/package.json
CHANGED
|
@@ -1,26 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakapiano/ccsm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.4",
|
|
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",
|
|
7
7
|
"bin": {
|
|
8
|
-
"ccsm": "./
|
|
8
|
+
"ccsm": "./bin/ccsm.js"
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"server.js",
|
|
12
|
+
"bin/",
|
|
12
13
|
"lib/",
|
|
13
14
|
"public/",
|
|
15
|
+
"scripts/",
|
|
14
16
|
"README.md",
|
|
15
17
|
"CLAUDE.md"
|
|
16
18
|
],
|
|
17
19
|
"scripts": {
|
|
18
20
|
"start": "node server.js",
|
|
19
|
-
"dev": "node --watch server.js"
|
|
21
|
+
"dev": "node --watch server.js",
|
|
22
|
+
"postinstall": "node scripts/install.js",
|
|
23
|
+
"preuninstall": "node scripts/uninstall.js"
|
|
20
24
|
},
|
|
21
25
|
"dependencies": {
|
|
22
26
|
"express": "^4.21.2"
|
|
23
27
|
},
|
|
28
|
+
"optionalDependencies": {
|
|
29
|
+
"node-pty": "^1.0.0",
|
|
30
|
+
"ws": "^8.18.0"
|
|
31
|
+
},
|
|
24
32
|
"engines": {
|
|
25
33
|
"node": ">=20"
|
|
26
34
|
},
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/* Reset, root typography, global accents, scrollbars, inline code/kbd. */
|
|
2
|
+
|
|
3
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
4
|
+
[hidden] { display: none !important; }
|
|
5
|
+
|
|
6
|
+
html {
|
|
7
|
+
/* Reserve the scrollbar lane so the layout never shifts horizontally
|
|
8
|
+
when content grows past one viewport. Both columns of scroll-gutter
|
|
9
|
+
stay symmetric (Firefox/Chromium both honor this). */
|
|
10
|
+
scrollbar-gutter: stable;
|
|
11
|
+
}
|
|
12
|
+
html, body {
|
|
13
|
+
background: var(--bg);
|
|
14
|
+
color: var(--ink);
|
|
15
|
+
font-family: var(--body);
|
|
16
|
+
font-size: 14px;
|
|
17
|
+
line-height: 1.5;
|
|
18
|
+
font-variant-numeric: tabular-nums;
|
|
19
|
+
min-height: 100vh;
|
|
20
|
+
-webkit-font-smoothing: antialiased;
|
|
21
|
+
-moz-osx-font-smoothing: grayscale;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
::selection { background: var(--ink); color: var(--bg-elev); }
|
|
25
|
+
|
|
26
|
+
code, .kbd {
|
|
27
|
+
font-family: var(--mono);
|
|
28
|
+
font-size: 11.5px;
|
|
29
|
+
padding: 1px 5px;
|
|
30
|
+
background: var(--bg);
|
|
31
|
+
border: 1px solid var(--border-soft);
|
|
32
|
+
border-radius: 4px;
|
|
33
|
+
color: var(--ink-mid);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* Fixed scrollbar look so it doesn't fade in/out or shift width on focus.
|
|
37
|
+
WebKit (Edge/Chrome): pseudo-elements. Firefox: scrollbar-* properties. */
|
|
38
|
+
* {
|
|
39
|
+
scrollbar-width: thin;
|
|
40
|
+
scrollbar-color: var(--border-strong) transparent;
|
|
41
|
+
}
|
|
42
|
+
::-webkit-scrollbar { width: 10px; height: 10px; background: transparent; }
|
|
43
|
+
::-webkit-scrollbar-track { background: transparent; }
|
|
44
|
+
::-webkit-scrollbar-thumb {
|
|
45
|
+
background: var(--border-strong);
|
|
46
|
+
border-radius: 8px;
|
|
47
|
+
border: 2px solid var(--bg);
|
|
48
|
+
/* Forced minimum height so dragging the thumb is always practical even on
|
|
49
|
+
very long pages. */
|
|
50
|
+
min-height: 32px;
|
|
51
|
+
}
|
|
52
|
+
::-webkit-scrollbar-thumb:hover { background: var(--ink-faint); }
|
|
53
|
+
::-webkit-scrollbar-corner { background: transparent; }
|
|
54
|
+
|
|
55
|
+
.row { display: flex; align-items: center; }
|
|
56
|
+
.gap-row { gap: var(--s-3); flex-wrap: wrap; }
|
|
57
|
+
.divider-dot { color: var(--ink-faint); padding: 0 var(--s-1); }
|
|
58
|
+
|
|
59
|
+
.muted-text { color: var(--ink-muted); font-size: 12.5px; }
|
|
60
|
+
.muted-text strong { color: var(--ink-mid); font-weight: 600; }
|
|
61
|
+
|
|
62
|
+
.post-result {
|
|
63
|
+
margin-top: var(--s-3);
|
|
64
|
+
font-family: var(--mono);
|
|
65
|
+
font-size: 11.5px;
|
|
66
|
+
color: var(--ink-muted);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.empty {
|
|
70
|
+
padding: var(--s-12) var(--s-6);
|
|
71
|
+
text-align: center;
|
|
72
|
+
font-size: 13px;
|
|
73
|
+
color: var(--ink-muted);
|
|
74
|
+
}
|
|
75
|
+
.empty code {
|
|
76
|
+
font-family: var(--mono);
|
|
77
|
+
font-size: 12px;
|
|
78
|
+
color: var(--ink-mid);
|
|
79
|
+
background: var(--bg);
|
|
80
|
+
padding: 1px 5px;
|
|
81
|
+
border-radius: 4px;
|
|
82
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/* Card surfaces · head with fold toggle · body · title with icon */
|
|
2
|
+
|
|
3
|
+
.card {
|
|
4
|
+
background: var(--bg-elev);
|
|
5
|
+
border: 1px solid var(--border);
|
|
6
|
+
border-radius: var(--r-md);
|
|
7
|
+
overflow: hidden;
|
|
8
|
+
box-shadow: var(--shadow);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.card-head {
|
|
12
|
+
padding: var(--s-4) var(--s-6) var(--s-3);
|
|
13
|
+
border-bottom: 1px solid var(--border-soft);
|
|
14
|
+
display: flex;
|
|
15
|
+
justify-content: flex-start;
|
|
16
|
+
align-items: center;
|
|
17
|
+
gap: var(--s-3);
|
|
18
|
+
}
|
|
19
|
+
/* Whole header clickable to fold, only when card is foldable */
|
|
20
|
+
.card[data-fold-key] .card-head {
|
|
21
|
+
cursor: pointer;
|
|
22
|
+
user-select: none;
|
|
23
|
+
transition: background .12s ease;
|
|
24
|
+
}
|
|
25
|
+
.card[data-fold-key] .card-head:hover {
|
|
26
|
+
background: var(--bg);
|
|
27
|
+
}
|
|
28
|
+
.card[data-collapsed] .card-head { border-bottom-color: transparent; }
|
|
29
|
+
.card-titles { flex: 1; min-width: 0; }
|
|
30
|
+
|
|
31
|
+
.card-fold {
|
|
32
|
+
appearance: none;
|
|
33
|
+
background: transparent;
|
|
34
|
+
border: 0;
|
|
35
|
+
padding: 4px;
|
|
36
|
+
margin: 0;
|
|
37
|
+
cursor: pointer;
|
|
38
|
+
color: var(--ink-muted);
|
|
39
|
+
display: inline-flex;
|
|
40
|
+
align-items: center;
|
|
41
|
+
justify-content: center;
|
|
42
|
+
border-radius: 4px;
|
|
43
|
+
transition: color .12s ease, background .12s ease, transform .25s cubic-bezier(.4, 0, .2, 1);
|
|
44
|
+
line-height: 0;
|
|
45
|
+
flex: 0 0 auto;
|
|
46
|
+
}
|
|
47
|
+
.card-fold:hover {
|
|
48
|
+
color: var(--ink);
|
|
49
|
+
background: var(--bg);
|
|
50
|
+
}
|
|
51
|
+
.card[data-collapsed] .card-fold { transform: rotate(-90deg); }
|
|
52
|
+
.card[data-collapsed] .card-body { display: none; }
|
|
53
|
+
|
|
54
|
+
.card-title {
|
|
55
|
+
font-size: 15.5px;
|
|
56
|
+
font-weight: 600;
|
|
57
|
+
letter-spacing: -0.012em;
|
|
58
|
+
color: var(--ink);
|
|
59
|
+
}
|
|
60
|
+
.card-title .title-icon {
|
|
61
|
+
color: var(--ink-mid);
|
|
62
|
+
margin-right: 6px;
|
|
63
|
+
vertical-align: -2px;
|
|
64
|
+
}
|
|
65
|
+
.card-title .title-icon-after {
|
|
66
|
+
margin-right: 0;
|
|
67
|
+
margin-left: 6px;
|
|
68
|
+
vertical-align: -1px;
|
|
69
|
+
}
|
|
70
|
+
.card-meta {
|
|
71
|
+
margin-top: 2px;
|
|
72
|
+
font-size: 12.5px;
|
|
73
|
+
color: var(--ink-muted);
|
|
74
|
+
font-family: var(--body);
|
|
75
|
+
}
|
|
76
|
+
.card-meta code {
|
|
77
|
+
font-family: var(--mono);
|
|
78
|
+
font-size: 11.5px;
|
|
79
|
+
color: var(--ink-mid);
|
|
80
|
+
background: var(--bg);
|
|
81
|
+
padding: 1px 5px;
|
|
82
|
+
border-radius: 4px;
|
|
83
|
+
border: 1px solid var(--border-soft);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.card-body { padding: var(--s-5) var(--s-6); }
|
|
87
|
+
.card-body-flush { padding: 0; }
|
|
88
|
+
|
|
89
|
+
/* ── About page ── */
|
|
90
|
+
.about-block { display: flex; flex-direction: column; gap: var(--s-5); max-width: 640px; }
|
|
91
|
+
.about-hero { display: flex; gap: var(--s-4); align-items: center; }
|
|
92
|
+
.about-mark { display: inline-flex; }
|
|
93
|
+
.about-mark svg { width: 48px; height: 48px; }
|
|
94
|
+
.about-name {
|
|
95
|
+
font-size: 20px;
|
|
96
|
+
font-weight: 600;
|
|
97
|
+
letter-spacing: -0.02em;
|
|
98
|
+
color: var(--ink);
|
|
99
|
+
}
|
|
100
|
+
.about-version {
|
|
101
|
+
font-family: var(--mono);
|
|
102
|
+
font-size: 12px;
|
|
103
|
+
color: var(--ink-muted);
|
|
104
|
+
margin-left: 6px;
|
|
105
|
+
font-weight: 400;
|
|
106
|
+
}
|
|
107
|
+
.about-tagline {
|
|
108
|
+
margin-top: 2px;
|
|
109
|
+
font-size: 13px;
|
|
110
|
+
color: var(--ink-mid);
|
|
111
|
+
}
|
|
112
|
+
.about-tagline code {
|
|
113
|
+
font-size: 11px;
|
|
114
|
+
padding: 1px 4px;
|
|
115
|
+
}
|
|
116
|
+
.about-copy {
|
|
117
|
+
font-size: 13.5px;
|
|
118
|
+
color: var(--ink-mid);
|
|
119
|
+
line-height: 1.6;
|
|
120
|
+
max-width: 560px;
|
|
121
|
+
}
|
|
122
|
+
.about-links {
|
|
123
|
+
display: flex;
|
|
124
|
+
gap: var(--s-3);
|
|
125
|
+
flex-wrap: wrap;
|
|
126
|
+
}
|
|
127
|
+
.about-links .action {
|
|
128
|
+
display: inline-flex;
|
|
129
|
+
align-items: center;
|
|
130
|
+
gap: 6px;
|
|
131
|
+
text-decoration: none;
|
|
132
|
+
}
|
|
133
|
+
.about-meta {
|
|
134
|
+
display: grid;
|
|
135
|
+
grid-template-columns: 140px 1fr;
|
|
136
|
+
gap: 8px var(--s-4);
|
|
137
|
+
font-size: 12.5px;
|
|
138
|
+
padding-top: var(--s-3);
|
|
139
|
+
border-top: 1px solid var(--border-soft);
|
|
140
|
+
}
|
|
141
|
+
.about-meta dt {
|
|
142
|
+
color: var(--ink-muted);
|
|
143
|
+
font-weight: 500;
|
|
144
|
+
text-transform: uppercase;
|
|
145
|
+
letter-spacing: 0.06em;
|
|
146
|
+
font-size: 10.5px;
|
|
147
|
+
padding-top: 2px;
|
|
148
|
+
}
|
|
149
|
+
.about-meta dd { color: var(--ink); }
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/* Page-actions CTA banner · server-status pill · toast ·
|
|
2
|
+
dirty-banner + save-pulse for unsaved settings */
|
|
3
|
+
|
|
4
|
+
/* CTA-style banner — neutral surface, ink left bar, distinct from .card
|
|
5
|
+
so it reads as "tip / shortcut" rather than another data section */
|
|
6
|
+
.page-actions {
|
|
7
|
+
display: flex;
|
|
8
|
+
align-items: center;
|
|
9
|
+
justify-content: space-between;
|
|
10
|
+
gap: var(--s-4);
|
|
11
|
+
padding: var(--s-3) var(--s-5);
|
|
12
|
+
background: var(--bg);
|
|
13
|
+
border: 1px solid var(--border);
|
|
14
|
+
border-radius: var(--r-sm);
|
|
15
|
+
box-shadow: none;
|
|
16
|
+
position: relative;
|
|
17
|
+
}
|
|
18
|
+
.page-actions::before {
|
|
19
|
+
content: "";
|
|
20
|
+
position: absolute;
|
|
21
|
+
left: 0; top: 8px; bottom: 8px;
|
|
22
|
+
width: 2px;
|
|
23
|
+
background: var(--ink);
|
|
24
|
+
border-radius: 0 2px 2px 0;
|
|
25
|
+
}
|
|
26
|
+
.page-actions-hint {
|
|
27
|
+
font-size: 13px;
|
|
28
|
+
color: var(--ink-mid);
|
|
29
|
+
font-weight: 500;
|
|
30
|
+
padding-left: var(--s-2);
|
|
31
|
+
}
|
|
32
|
+
.page-actions .action svg { stroke-width: 2; }
|
|
33
|
+
|
|
34
|
+
/* "Refresh · 12s ago" timestamp inside the top-right Refresh button */
|
|
35
|
+
.refresh-ago {
|
|
36
|
+
margin-left: 6px;
|
|
37
|
+
padding-left: 7px;
|
|
38
|
+
border-left: 1px solid var(--border);
|
|
39
|
+
font-family: var(--mono);
|
|
40
|
+
font-size: 10.5px;
|
|
41
|
+
color: var(--ink-muted);
|
|
42
|
+
font-variant-numeric: tabular-nums;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* Top-right page-head-meta · server-status pill + Refresh button share
|
|
46
|
+
the same height, padding, font-size and pill shape so they read as a
|
|
47
|
+
coherent control group. Explicit min-height keeps them aligned even
|
|
48
|
+
though they use different fonts and one carries an icon. */
|
|
49
|
+
.page-head-meta { align-items: center; }
|
|
50
|
+
.page-head-meta .action.small,
|
|
51
|
+
.page-head-meta .server-status {
|
|
52
|
+
min-height: 28px;
|
|
53
|
+
border-radius: 999px;
|
|
54
|
+
box-sizing: border-box;
|
|
55
|
+
line-height: 1;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* "● online v0.5.0" pill in top-right page-head-meta */
|
|
59
|
+
.server-status {
|
|
60
|
+
display: inline-flex;
|
|
61
|
+
align-items: center;
|
|
62
|
+
gap: 6px;
|
|
63
|
+
padding: 4px 10px;
|
|
64
|
+
border-radius: 999px;
|
|
65
|
+
background: var(--bg);
|
|
66
|
+
border: 1px solid var(--border);
|
|
67
|
+
font-family: var(--mono);
|
|
68
|
+
font-size: 12px;
|
|
69
|
+
letter-spacing: 0.02em;
|
|
70
|
+
color: var(--ink-mid);
|
|
71
|
+
transition: background .15s ease, border-color .15s ease, color .15s ease;
|
|
72
|
+
cursor: default;
|
|
73
|
+
}
|
|
74
|
+
.server-status .status-pulse {
|
|
75
|
+
width: 7px;
|
|
76
|
+
height: 7px;
|
|
77
|
+
border-radius: 50%;
|
|
78
|
+
background: var(--ink-faint);
|
|
79
|
+
flex: 0 0 7px;
|
|
80
|
+
position: relative;
|
|
81
|
+
}
|
|
82
|
+
.server-status[data-state="online"] {
|
|
83
|
+
border-color: rgba(74, 138, 74, 0.35);
|
|
84
|
+
background: rgba(74, 138, 74, 0.06);
|
|
85
|
+
color: var(--green);
|
|
86
|
+
}
|
|
87
|
+
.server-status[data-state="online"] .status-pulse {
|
|
88
|
+
background: var(--green);
|
|
89
|
+
animation: server-pulse 2.2s ease-in-out infinite;
|
|
90
|
+
}
|
|
91
|
+
.server-status[data-state="offline"] {
|
|
92
|
+
border-color: rgba(183, 63, 63, 0.4);
|
|
93
|
+
background: rgba(183, 63, 63, 0.06);
|
|
94
|
+
color: var(--red);
|
|
95
|
+
}
|
|
96
|
+
.server-status[data-state="offline"] .status-pulse { background: var(--red); }
|
|
97
|
+
.server-status[data-state="connecting"] {
|
|
98
|
+
border-color: rgba(196, 137, 43, 0.4);
|
|
99
|
+
background: rgba(196, 137, 43, 0.06);
|
|
100
|
+
color: var(--yellow);
|
|
101
|
+
}
|
|
102
|
+
.server-status[data-state="connecting"] .status-pulse {
|
|
103
|
+
background: var(--yellow);
|
|
104
|
+
animation: server-pulse 1s ease-in-out infinite;
|
|
105
|
+
}
|
|
106
|
+
@keyframes server-pulse {
|
|
107
|
+
0%, 100% { box-shadow: 0 0 0 0 currentColor; }
|
|
108
|
+
50% { box-shadow: 0 0 0 4px transparent; }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* Offline banner · shown when /api/health is unreachable. Sticks to the
|
|
112
|
+
top of the main column above all cards. Contains a ccsm:// link that
|
|
113
|
+
asks Windows to spawn the backend. */
|
|
114
|
+
.offline-banner {
|
|
115
|
+
background: #fff7e8;
|
|
116
|
+
border: 1px solid #d4b27a;
|
|
117
|
+
border-radius: var(--r-sm);
|
|
118
|
+
padding: var(--s-3) var(--s-5);
|
|
119
|
+
position: sticky;
|
|
120
|
+
top: var(--s-3);
|
|
121
|
+
z-index: 50;
|
|
122
|
+
box-shadow: 0 3px 10px -8px rgba(26, 24, 21, 0.15);
|
|
123
|
+
}
|
|
124
|
+
.offline-banner-inner {
|
|
125
|
+
display: flex;
|
|
126
|
+
align-items: center;
|
|
127
|
+
gap: var(--s-3);
|
|
128
|
+
}
|
|
129
|
+
.offline-banner-text {
|
|
130
|
+
flex: 1;
|
|
131
|
+
display: flex;
|
|
132
|
+
flex-direction: column;
|
|
133
|
+
gap: 2px;
|
|
134
|
+
font-size: 13px;
|
|
135
|
+
color: var(--ink);
|
|
136
|
+
}
|
|
137
|
+
.offline-banner-text .muted-text {
|
|
138
|
+
font-size: 11.5px;
|
|
139
|
+
color: var(--ink-mid);
|
|
140
|
+
}
|
|
141
|
+
.offline-banner-actions { display: flex; gap: var(--s-2); }
|
|
142
|
+
.offline-dot {
|
|
143
|
+
width: 10px;
|
|
144
|
+
height: 10px;
|
|
145
|
+
border-radius: 50%;
|
|
146
|
+
background: var(--red);
|
|
147
|
+
flex: 0 0 10px;
|
|
148
|
+
animation: dirty-pulse 2s ease-in-out infinite;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/* Toast bottom-right — auto-hides via JS */
|
|
152
|
+
.toast {
|
|
153
|
+
position: fixed;
|
|
154
|
+
bottom: var(--s-6);
|
|
155
|
+
right: var(--s-6);
|
|
156
|
+
z-index: 100;
|
|
157
|
+
max-width: 420px;
|
|
158
|
+
padding: var(--s-3) var(--s-4);
|
|
159
|
+
background: var(--bg-elev);
|
|
160
|
+
border: 1px solid var(--border-strong);
|
|
161
|
+
border-left: 3px solid var(--ink);
|
|
162
|
+
border-radius: var(--r-sm);
|
|
163
|
+
font-size: 13px;
|
|
164
|
+
color: var(--ink);
|
|
165
|
+
letter-spacing: -0.005em;
|
|
166
|
+
opacity: 0;
|
|
167
|
+
transform: translateY(8px);
|
|
168
|
+
transition: opacity .2s ease, transform .2s ease;
|
|
169
|
+
pointer-events: none;
|
|
170
|
+
box-shadow: var(--shadow-md);
|
|
171
|
+
}
|
|
172
|
+
.toast.show {
|
|
173
|
+
opacity: 1;
|
|
174
|
+
transform: translateY(0);
|
|
175
|
+
}
|
|
176
|
+
.toast.error { border-left-color: var(--red); }
|
|
177
|
+
.toast.ok { border-left-color: var(--green); }
|
|
178
|
+
|
|
179
|
+
/* Sticky "you have unsaved changes" banner above the Configure card */
|
|
180
|
+
.dirty-banner {
|
|
181
|
+
display: flex;
|
|
182
|
+
align-items: center;
|
|
183
|
+
gap: var(--s-3);
|
|
184
|
+
padding: var(--s-3) var(--s-5);
|
|
185
|
+
background: var(--bg);
|
|
186
|
+
border: 1px solid var(--ink);
|
|
187
|
+
border-radius: var(--r-sm);
|
|
188
|
+
color: var(--ink);
|
|
189
|
+
font-size: 13px;
|
|
190
|
+
font-weight: 500;
|
|
191
|
+
position: sticky;
|
|
192
|
+
top: var(--s-3);
|
|
193
|
+
z-index: 10;
|
|
194
|
+
box-shadow: 0 3px 10px -8px rgba(26, 24, 21, 0.15);
|
|
195
|
+
animation: banner-in .25s cubic-bezier(.4, 0, .2, 1);
|
|
196
|
+
}
|
|
197
|
+
@keyframes banner-in {
|
|
198
|
+
from { opacity: 0; transform: translateY(-6px); }
|
|
199
|
+
to { opacity: 1; transform: translateY(0); }
|
|
200
|
+
}
|
|
201
|
+
.dirty-banner .dirty-dot {
|
|
202
|
+
width: 8px;
|
|
203
|
+
height: 8px;
|
|
204
|
+
border-radius: 50%;
|
|
205
|
+
background: var(--ink);
|
|
206
|
+
flex: 0 0 8px;
|
|
207
|
+
box-shadow: 0 0 0 0 rgba(26, 24, 21, 0.35);
|
|
208
|
+
animation: dirty-pulse 2s ease-in-out infinite;
|
|
209
|
+
}
|
|
210
|
+
.dirty-banner .dirty-text { flex: 1; }
|
|
211
|
+
|
|
212
|
+
/* Save button pulse — mirrors dirty banner */
|
|
213
|
+
.action.primary.is-dirty {
|
|
214
|
+
animation: save-pulse 1.6s ease-in-out infinite;
|
|
215
|
+
}
|
|
216
|
+
@keyframes save-pulse {
|
|
217
|
+
0%, 100% { box-shadow: 0 0 0 0 rgba(26, 24, 21, 0.25); }
|
|
218
|
+
50% { box-shadow: 0 0 0 4px rgba(26, 24, 21, 0); }
|
|
219
|
+
}
|