stackdeck 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -7
- package/index.html +643 -134
- package/package.json +30 -7
- package/server.js +19 -1
package/index.html
CHANGED
|
@@ -13,14 +13,18 @@
|
|
|
13
13
|
--border: oklch(0.27 0.012 75);
|
|
14
14
|
--hairline: oklch(0.23 0.010 75);
|
|
15
15
|
--text: oklch(0.93 0.010 85);
|
|
16
|
-
--dim: oklch(0.
|
|
17
|
-
--faint: oklch(0.
|
|
16
|
+
--dim: oklch(0.70 0.014 80);
|
|
17
|
+
--faint: oklch(0.60 0.012 80);
|
|
18
|
+
--border-strong: oklch(0.52 0.012 75);
|
|
18
19
|
--accent: oklch(0.78 0.115 68);
|
|
19
20
|
--accent-dim:oklch(0.78 0.115 68 / .14);
|
|
20
21
|
--green: oklch(0.74 0.145 152);
|
|
21
22
|
--green-dim: oklch(0.74 0.145 152 / .13);
|
|
22
23
|
--red: oklch(0.66 0.17 26);
|
|
23
24
|
--red-dim: oklch(0.66 0.17 26 / .13);
|
|
25
|
+
--r-sm: 5px; --r: 7px; --r-lg: 10px;
|
|
26
|
+
--ease: cubic-bezier(0.25, 1, 0.5, 1); /* ease-out-quart */
|
|
27
|
+
--dur: .15s;
|
|
24
28
|
--mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, monospace;
|
|
25
29
|
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
|
26
30
|
}
|
|
@@ -29,16 +33,16 @@
|
|
|
29
33
|
body { margin:0; background:var(--bg); color:var(--text); font:14px/1.5 var(--sans); }
|
|
30
34
|
|
|
31
35
|
header {
|
|
32
|
-
display:flex; align-items:baseline; gap:14px;
|
|
33
|
-
padding:22px 28px 0; max-width:
|
|
36
|
+
display:flex; align-items:baseline; gap:14px; flex-wrap:wrap;
|
|
37
|
+
padding:22px 28px 0; max-width:1200px; margin:0 auto;
|
|
34
38
|
}
|
|
35
39
|
header h1 { font-size:16px; font-weight:650; margin:0; letter-spacing:.01em; }
|
|
36
40
|
header h1 span { color:var(--accent); }
|
|
37
|
-
#summary { color:var(--dim); font-size:
|
|
41
|
+
#summary { color:var(--dim); font-size:13px; }
|
|
38
42
|
header .spacer { flex:1; }
|
|
39
43
|
header .paths { color:var(--faint); font-size:11px; font-family:var(--mono); }
|
|
40
44
|
|
|
41
|
-
main { max-width:
|
|
45
|
+
main { max-width:1200px; margin:0 auto; padding:10px 28px 96px; }
|
|
42
46
|
|
|
43
47
|
.section-label {
|
|
44
48
|
margin:36px 2px 10px; font-size:11px; font-weight:600; letter-spacing:.1em;
|
|
@@ -46,14 +50,14 @@
|
|
|
46
50
|
display:flex; align-items:center; gap:10px;
|
|
47
51
|
}
|
|
48
52
|
.section-label .count { color:var(--dim); font-weight:500; letter-spacing:0; text-transform:none; }
|
|
49
|
-
.section-label button { font-size:
|
|
53
|
+
.section-label button { font-size:12px; padding:3px 9px; }
|
|
50
54
|
.section-label input {
|
|
51
|
-
margin-left:auto; width:220px; font-size:
|
|
55
|
+
margin-left:auto; width:220px; font-size:13px; letter-spacing:0; text-transform:none; font-weight:400;
|
|
52
56
|
}
|
|
53
57
|
|
|
54
|
-
.ledger { background:var(--surface); border:1px solid var(--border); border-radius:
|
|
58
|
+
.ledger { background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); overflow:hidden; }
|
|
55
59
|
|
|
56
|
-
.row { border-top:1px solid var(--hairline); transition:background
|
|
60
|
+
.row { border-top:1px solid var(--hairline); transition:background var(--dur) var(--ease); }
|
|
57
61
|
.row:first-child { border-top:none; }
|
|
58
62
|
.row:hover { background:var(--surface-2); }
|
|
59
63
|
.row-main { display:flex; align-items:center; gap:12px; padding:11px 16px; }
|
|
@@ -63,31 +67,32 @@
|
|
|
63
67
|
border:1.5px solid var(--faint); flex:none; }
|
|
64
68
|
.dot.up { background:var(--green); border-color:var(--green); box-shadow:0 0 0 3px var(--green-dim); }
|
|
65
69
|
|
|
66
|
-
.svc-name { font-weight:600; font-size:
|
|
70
|
+
.svc-name { font-weight:600; font-size:14px; min-width:96px; }
|
|
67
71
|
.addr { font-family:var(--mono); font-size:12px; color:var(--dim); white-space:nowrap; }
|
|
68
72
|
.grow { flex:1; min-width:0; }
|
|
69
73
|
.cmdline {
|
|
70
|
-
font-family:var(--mono); font-size:
|
|
74
|
+
font-family:var(--mono); font-size:12px; color:var(--faint);
|
|
71
75
|
overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
|
|
72
76
|
}
|
|
73
77
|
.cmdline b { color:var(--dim); font-weight:500; }
|
|
74
78
|
|
|
75
79
|
.badge {
|
|
76
|
-
font-size:
|
|
77
|
-
border-radius:
|
|
80
|
+
font-size:11px; font-weight:600; letter-spacing:.03em; padding:2px 7px;
|
|
81
|
+
border-radius:var(--r-sm); white-space:nowrap;
|
|
78
82
|
}
|
|
79
|
-
.badge.ext { color:var(--accent); background:
|
|
83
|
+
.badge.ext { color:var(--accent); background:transparent; box-shadow:inset 0 0 0 1px oklch(0.78 0.115 68 / .45); }
|
|
80
84
|
.badge.dirty { color:var(--accent); background:var(--accent-dim); }
|
|
85
|
+
.badge.wt { color:var(--dim); background:oklch(0.30 0.012 75 / .5); }
|
|
81
86
|
.badge.git { color:var(--dim); background:oklch(0.30 0.012 75 / .5); font-family:var(--mono); font-weight:500; max-width:220px; overflow:hidden; text-overflow:ellipsis; }
|
|
82
|
-
.badge.plain { color:var(--
|
|
87
|
+
.badge.plain { color:var(--dim); background:oklch(0.30 0.012 75 / .35); }
|
|
83
88
|
.badge.cfgd { color:var(--green); background:var(--green-dim); }
|
|
84
89
|
.badge.crash { color:var(--red); background:var(--red-dim); }
|
|
85
90
|
a.badge.link { color:var(--accent); background:var(--accent-dim); text-decoration:none; }
|
|
86
91
|
a.badge.link:hover { text-decoration:underline; }
|
|
87
|
-
.logfilter { width:200px; margin:0 0 6px; font-size:
|
|
92
|
+
.logfilter { width:200px; margin:0 0 6px; font-size:12px; padding:3px 8px; }
|
|
88
93
|
|
|
89
94
|
select, input, textarea {
|
|
90
|
-
background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:
|
|
95
|
+
background:var(--bg); color:var(--text); border:1px solid var(--border-strong); border-radius:var(--r);
|
|
91
96
|
padding:5px 9px; font-family:var(--mono); font-size:12px;
|
|
92
97
|
}
|
|
93
98
|
select:focus-visible, input:focus-visible, textarea:focus-visible, button:focus-visible {
|
|
@@ -96,25 +101,46 @@
|
|
|
96
101
|
select:disabled { opacity:.4; cursor:not-allowed; }
|
|
97
102
|
|
|
98
103
|
/* fixed-width slots keep every row's controls in the same columns */
|
|
99
|
-
.branch-slot { width:220px; flex:none; display:flex; justify-content:flex-end; }
|
|
104
|
+
.branch-slot { width:clamp(150px, 16vw, 220px); flex:none; display:flex; justify-content:flex-end; }
|
|
100
105
|
.branch-slot select { width:100%; }
|
|
101
|
-
|
|
106
|
+
/* dirty = amber border plus a dot glyph, so the state isn't color-alone */
|
|
107
|
+
.branch-slot select.dirty {
|
|
108
|
+
border-color:var(--accent);
|
|
109
|
+
background-image:radial-gradient(circle, var(--accent) 2px, transparent 2.6px);
|
|
110
|
+
background-repeat:no-repeat; background-position:right 24px center;
|
|
111
|
+
}
|
|
102
112
|
.row-actions { display:flex; gap:2px; flex:none; align-items:center; }
|
|
103
|
-
.row-actions .primary { width:74px; text-align:center; }
|
|
113
|
+
.row-actions .primary { min-width:74px; text-align:center; }
|
|
104
114
|
.addr { width:110px; flex:none; text-align:right; }
|
|
105
115
|
|
|
106
116
|
/* Secondary actions stay quiet until the row (or section head) is engaged —
|
|
107
117
|
the board reads as status, not as a wall of buttons. */
|
|
108
118
|
.row-actions .ghost, .group-head .actions button, .row .add {
|
|
109
|
-
opacity:0; transition:opacity
|
|
119
|
+
opacity:0; transition:opacity var(--dur) var(--ease);
|
|
110
120
|
}
|
|
111
121
|
.row:hover .row-actions .ghost, .row:focus-within .row-actions .ghost,
|
|
112
122
|
.group-head:hover .actions button, .group-head:focus-within .actions button,
|
|
113
123
|
.row:hover .add, .row:focus-within .add { opacity:1; }
|
|
124
|
+
.cat-sel { opacity:0; transition:opacity var(--dur) var(--ease); max-width:150px; font-size:11px; padding:2px 6px; }
|
|
125
|
+
.row:hover .cat-sel, .row:focus-within .cat-sel { opacity:1; }
|
|
114
126
|
|
|
115
|
-
/* touch devices have no hover — keep actions visible
|
|
127
|
+
/* touch devices have no hover — keep actions visible and give them room */
|
|
116
128
|
@media (hover: none) {
|
|
117
|
-
.row-actions .ghost, .group-head .actions button, .row .add { opacity:1; }
|
|
129
|
+
.row-actions .ghost, .group-head .actions button, .row .add, .cat-sel { opacity:1; }
|
|
130
|
+
button { padding:10px 14px; }
|
|
131
|
+
.group-head button { padding:7px 10px; }
|
|
132
|
+
.row-actions { gap:6px; }
|
|
133
|
+
}
|
|
134
|
+
/* split-window / narrow: let rows wrap instead of clipping actions */
|
|
135
|
+
@media (max-width: 900px) {
|
|
136
|
+
header, main { padding-left:16px; padding-right:16px; }
|
|
137
|
+
.row-main { flex-wrap:wrap; row-gap:6px; }
|
|
138
|
+
.row-main .cmdline { order:10; flex-basis:100%; padding-left:20px; }
|
|
139
|
+
.row-main .row-actions { margin-left:auto; }
|
|
140
|
+
.branch-slot { width:auto; min-width:150px; }
|
|
141
|
+
.addr { width:auto; }
|
|
142
|
+
.section-label { flex-wrap:wrap; }
|
|
143
|
+
.section-label input { width:100%; margin-left:0; }
|
|
118
144
|
}
|
|
119
145
|
@media (prefers-reduced-motion: reduce) {
|
|
120
146
|
*, *::before, *::after { transition:none !important; animation:none !important; }
|
|
@@ -122,10 +148,10 @@
|
|
|
122
148
|
textarea { width:100%; resize:vertical; min-height:34px; }
|
|
123
149
|
|
|
124
150
|
button {
|
|
125
|
-
font:600
|
|
126
|
-
background:var(--surface-2); border:1px solid var(--border); border-radius:
|
|
151
|
+
font:600 13px var(--sans); color:var(--text);
|
|
152
|
+
background:var(--surface-2); border:1px solid var(--border); border-radius:var(--r);
|
|
127
153
|
padding:5px 13px; cursor:pointer;
|
|
128
|
-
transition:border-color
|
|
154
|
+
transition:border-color var(--dur) var(--ease), background var(--dur) var(--ease), color var(--dur) var(--ease);
|
|
129
155
|
}
|
|
130
156
|
button:hover { border-color:var(--dim); }
|
|
131
157
|
button:active { transform:translateY(0.5px); }
|
|
@@ -133,28 +159,33 @@
|
|
|
133
159
|
button.start { color:var(--green); background:var(--green-dim); border-color:transparent; }
|
|
134
160
|
button.start:hover { background:var(--green); color:oklch(0.15 0.02 152); }
|
|
135
161
|
button.kill { color:var(--red); background:var(--red-dim); border-color:transparent; }
|
|
136
|
-
button.kill:hover { background:var(--red); color:oklch(0.
|
|
162
|
+
button.kill:hover { background:var(--red); color:oklch(0.13 0.02 26); }
|
|
137
163
|
button.ghost { background:transparent; border-color:transparent; color:var(--dim); font-weight:500; }
|
|
138
164
|
button.ghost:hover { color:var(--text); border-color:var(--border); }
|
|
139
165
|
button.add { color:var(--accent); background:var(--accent-dim); border-color:transparent; }
|
|
140
166
|
button.add:hover { background:var(--accent); color:oklch(0.16 0.02 68); }
|
|
141
167
|
|
|
142
168
|
.drawer { display:none; padding:4px 16px 14px 36px; }
|
|
143
|
-
|
|
169
|
+
@keyframes drawerIn { from { opacity:0; transform:translateY(-3px); } }
|
|
170
|
+
.drawer.open { display:block; animation:drawerIn .18s var(--ease); }
|
|
171
|
+
@keyframes badgeIn { from { opacity:0; transform:scale(.92); } }
|
|
172
|
+
.badge.crash, .badge.dirty { animation:badgeIn .22s var(--ease); }
|
|
144
173
|
.fields { display:grid; grid-template-columns:64px 1fr; gap:7px 12px; align-items:center; max-width:720px; }
|
|
145
|
-
.fields label { color:var(--faint); font-size:
|
|
174
|
+
.fields label { color:var(--faint); font-size:12px; text-align:right; }
|
|
146
175
|
|
|
176
|
+
/* the log pane is a terminal: always dark, own text colors, in every theme
|
|
177
|
+
(ANSI palette + readability hold even when the board itself is light) */
|
|
147
178
|
.logs {
|
|
148
|
-
background:oklch(0.10 0.006 75); border:1px solid var(--hairline); border-radius:
|
|
179
|
+
background:oklch(0.10 0.006 75); border:1px solid var(--hairline); border-radius:var(--r);
|
|
149
180
|
padding:10px 12px; height:250px; overflow:auto; overscroll-behavior:contain;
|
|
150
|
-
font-family:var(--mono); font-size:
|
|
181
|
+
font-family:var(--mono); font-size:12px; line-height:1.55; white-space:pre-wrap; color:oklch(0.72 0.010 80);
|
|
151
182
|
}
|
|
152
|
-
.logs:empty::before { content:"waiting for output…"; color:
|
|
183
|
+
.logs:empty::before { content:"waiting for output…"; color:oklch(0.62 0.010 80); font-style:italic; }
|
|
153
184
|
.logs::-webkit-scrollbar { width:8px; }
|
|
154
185
|
.logs::-webkit-scrollbar-thumb { background:var(--border); border-radius:4px; }
|
|
155
186
|
.logs::-webkit-scrollbar-track { background:transparent; }
|
|
156
187
|
|
|
157
|
-
.empty { padding:26px 16px; text-align:center; color:var(--faint); font-size:
|
|
188
|
+
.empty { padding:26px 16px; text-align:center; color:var(--faint); font-size:13px; }
|
|
158
189
|
|
|
159
190
|
/* drag & drop */
|
|
160
191
|
.handle {
|
|
@@ -168,23 +199,27 @@
|
|
|
168
199
|
.group-block { margin-bottom:18px; }
|
|
169
200
|
.group-head {
|
|
170
201
|
display:flex; align-items:center; gap:8px; margin:0 2px 6px;
|
|
171
|
-
font-size:
|
|
202
|
+
font-size:12px; font-weight:600; color:var(--dim); letter-spacing:.04em;
|
|
172
203
|
}
|
|
173
204
|
.group-head .count { color:var(--faint); font-weight:500; }
|
|
174
205
|
.group-head button { padding:1px 7px; font-size:11px; }
|
|
175
206
|
.group-head .actions { margin-left:auto; display:flex; gap:4px; }
|
|
176
207
|
.hiddenItem { opacity:.45; }
|
|
177
|
-
.
|
|
178
|
-
|
|
179
|
-
|
|
208
|
+
button.title {
|
|
209
|
+
display:flex; align-items:center; gap:8px; cursor:pointer; user-select:none;
|
|
210
|
+
background:none; border:none; padding:2px 4px; margin:0;
|
|
211
|
+
font:inherit; font-weight:600; color:inherit; letter-spacing:inherit;
|
|
212
|
+
}
|
|
213
|
+
button.title:hover { color:var(--text); }
|
|
214
|
+
button.chev-btn { background:none; border:none; padding:2px 6px; cursor:pointer; color:var(--faint); }
|
|
215
|
+
button.chev-btn:hover { color:var(--dim); }
|
|
216
|
+
.chev { display:inline-block; transition:transform var(--dur) var(--ease); font-size:10px; color:var(--faint); }
|
|
180
217
|
.chev.closed { transform:rotate(-90deg); }
|
|
181
218
|
|
|
182
219
|
/* settings: folder tree */
|
|
183
|
-
.cfg-label { font-size:11.5px; font-weight:600; color:var(--dim); letter-spacing:.04em; margin:2px 0 8px; }
|
|
184
|
-
.cfg-label .count { color:var(--faint); font-weight:500; }
|
|
185
220
|
.root-row {
|
|
186
221
|
display:flex; align-items:center; gap:10px; padding:6px 0;
|
|
187
|
-
font-family:var(--mono); font-size:
|
|
222
|
+
font-family:var(--mono); font-size:13px;
|
|
188
223
|
}
|
|
189
224
|
.root-row .chev { cursor:pointer; padding:4px; }
|
|
190
225
|
.child-grid {
|
|
@@ -198,62 +233,256 @@
|
|
|
198
233
|
.child-grid label.off { color:var(--faint); text-decoration:line-through; }
|
|
199
234
|
.child-grid input { accent-color:var(--accent); }
|
|
200
235
|
.browser {
|
|
201
|
-
border:1px solid var(--border); border-radius:
|
|
236
|
+
border:1px solid var(--border); border-radius:var(--r); padding:10px 12px; margin:8px 0;
|
|
202
237
|
max-width:560px; background:var(--bg);
|
|
203
238
|
}
|
|
204
239
|
.browser .crumb { font-family:var(--mono); font-size:12px; color:var(--dim); margin-bottom:8px; word-break:break-all; }
|
|
205
240
|
.browser .dirs { max-height:220px; overflow:auto; display:grid; gap:1px; }
|
|
206
241
|
.browser .dirs button {
|
|
207
242
|
text-align:left; background:transparent; border-color:transparent; color:var(--text);
|
|
208
|
-
font:500
|
|
243
|
+
font:500 13px var(--mono); padding:3px 8px;
|
|
209
244
|
}
|
|
210
245
|
.browser .dirs button:hover { background:var(--surface-2); }
|
|
211
246
|
.cat-list { display:grid; gap:2px; max-width:380px; }
|
|
212
247
|
.cat-row {
|
|
213
248
|
display:flex; align-items:center; gap:9px; padding:5px 10px;
|
|
214
|
-
border:1px solid var(--hairline); border-radius:
|
|
215
|
-
font-size:
|
|
249
|
+
border:1px solid var(--hairline); border-radius:var(--r); background:var(--bg);
|
|
250
|
+
font-size:13px; cursor:grab; user-select:none;
|
|
216
251
|
}
|
|
217
|
-
.cat-row .count { color:var(--faint); margin-left:auto; font-size:
|
|
252
|
+
.cat-row .count { color:var(--faint); margin-left:auto; font-size:12px; }
|
|
218
253
|
.cat-row.dragging { opacity:.35; }
|
|
219
254
|
.cat-row.drop-before { box-shadow: inset 0 2px 0 var(--accent); }
|
|
220
255
|
.cat-row.pinned { color:var(--faint); cursor:default; border-style:dashed; }
|
|
221
256
|
|
|
222
257
|
.toast {
|
|
223
258
|
position:fixed; bottom:22px; left:50%; transform:translate(-50%, 8px);
|
|
224
|
-
background:var(--surface-2); border:1px solid var(--border); border-radius:
|
|
259
|
+
background:var(--surface-2); border:1px solid var(--border); border-radius:var(--r-lg);
|
|
225
260
|
padding:10px 18px; max-width:70%; font-size:13px;
|
|
226
|
-
opacity:0; pointer-events:none; transition:opacity .2s ease
|
|
261
|
+
opacity:0; pointer-events:none; transition:opacity .2s var(--ease), transform .2s var(--ease);
|
|
227
262
|
}
|
|
228
263
|
.toast.show { opacity:1; transform:translate(-50%, 0); }
|
|
229
|
-
.toast.err { border-color:var(--red); color:
|
|
264
|
+
.toast.err { border-color:var(--red); color:var(--red); }
|
|
265
|
+
input[type="range"] { accent-color:var(--accent); padding:0; border:none; background:transparent; }
|
|
266
|
+
|
|
267
|
+
/* ---- theme: playful ---- */
|
|
268
|
+
[data-theme="playful"] {
|
|
269
|
+
--bg: oklch(0.17 0.03 200);
|
|
270
|
+
--surface: oklch(0.21 0.035 205);
|
|
271
|
+
--surface-2: oklch(0.25 0.04 210);
|
|
272
|
+
--border: oklch(0.33 0.045 210);
|
|
273
|
+
--hairline: oklch(0.27 0.04 208);
|
|
274
|
+
--text: oklch(0.95 0.015 90);
|
|
275
|
+
--dim: oklch(0.75 0.03 190);
|
|
276
|
+
--faint: oklch(0.64 0.03 195);
|
|
277
|
+
--border-strong: oklch(0.55 0.04 200);
|
|
278
|
+
--accent: oklch(0.74 0.16 35);
|
|
279
|
+
--accent-dim:oklch(0.74 0.16 35 / .16);
|
|
280
|
+
--green: oklch(0.78 0.16 155);
|
|
281
|
+
--green-dim: oklch(0.78 0.16 155 / .15);
|
|
282
|
+
--red: oklch(0.68 0.19 20);
|
|
283
|
+
--red-dim: oklch(0.68 0.19 20 / .15);
|
|
284
|
+
--r-sm: 999px; --r: 11px; --r-lg: 16px;
|
|
285
|
+
--ease: cubic-bezier(0.22, 1, 0.36, 1); /* ease-out-quint: a touch livelier */
|
|
286
|
+
--dur: .2s;
|
|
287
|
+
}
|
|
288
|
+
[data-theme="playful"] .dot { width:10px; height:10px; }
|
|
289
|
+
[data-theme="playful"] .dot.up { box-shadow:0 0 0 4px var(--green-dim), 0 0 12px oklch(0.78 0.16 155 / .35); }
|
|
290
|
+
[data-theme="playful"] .badge { padding:2px 9px; }
|
|
291
|
+
[data-theme="playful"] button:hover { transform:translateY(-1px); }
|
|
292
|
+
[data-theme="playful"] button:active { transform:translateY(0.5px); }
|
|
293
|
+
[data-theme="playful"] .section-label { color:var(--accent); }
|
|
294
|
+
[data-theme="playful"] .drawer.open { animation-duration:.24s; }
|
|
295
|
+
|
|
296
|
+
/* ---- theme: austere ---- */
|
|
297
|
+
[data-theme="austere"] {
|
|
298
|
+
--bg: oklch(0.13 0.003 80);
|
|
299
|
+
--surface: oklch(0.15 0.004 80);
|
|
300
|
+
--surface-2: oklch(0.19 0.005 80);
|
|
301
|
+
--border: oklch(0.28 0.005 80);
|
|
302
|
+
--hairline: oklch(0.22 0.004 80);
|
|
303
|
+
--text: oklch(0.90 0.004 85);
|
|
304
|
+
--dim: oklch(0.68 0.005 85);
|
|
305
|
+
--faint: oklch(0.58 0.005 85);
|
|
306
|
+
--border-strong: oklch(0.50 0.005 85);
|
|
307
|
+
--accent: oklch(0.85 0.02 85); /* selection reads as brightness, not hue */
|
|
308
|
+
--accent-dim:oklch(0.85 0.02 85 / .10);
|
|
309
|
+
--green: oklch(0.78 0.09 150);
|
|
310
|
+
--green-dim: oklch(0.78 0.09 150 / .10);
|
|
311
|
+
--red: oklch(0.70 0.11 25);
|
|
312
|
+
--red-dim: oklch(0.70 0.11 25 / .10);
|
|
313
|
+
--r-sm: 2px; --r: 3px; --r-lg: 4px;
|
|
314
|
+
--dur: .08s; /* austere: near-instant state changes */
|
|
315
|
+
}
|
|
316
|
+
[data-theme="austere"] body { font-family:var(--mono); }
|
|
317
|
+
[data-theme="austere"] .dot.up { box-shadow:none; }
|
|
318
|
+
[data-theme="austere"] button { background:transparent; border-color:var(--border); }
|
|
319
|
+
[data-theme="austere"] button.start { border-color:oklch(0.78 0.09 150 / .4); background:transparent; }
|
|
320
|
+
[data-theme="austere"] button.kill { border-color:oklch(0.70 0.11 25 / .4); background:transparent; }
|
|
321
|
+
[data-theme="austere"] button.add { border-color:oklch(0.85 0.02 85 / .4); background:transparent; }
|
|
322
|
+
[data-theme="austere"] .section-label { letter-spacing:.18em; }
|
|
323
|
+
[data-theme="austere"] .drawer.open, [data-theme="austere"] .badge.crash, [data-theme="austere"] .badge.dirty { animation:none; }
|
|
324
|
+
|
|
325
|
+
/* settings panel: title + hint rail on the left, controls on the right */
|
|
326
|
+
.cfg-head {
|
|
327
|
+
display:flex; align-items:center; justify-content:space-between;
|
|
328
|
+
padding:13px 18px; border-bottom:1px solid var(--hairline);
|
|
329
|
+
font-size:13px; font-weight:650;
|
|
330
|
+
}
|
|
331
|
+
.cfg-head button { font-size:15px; padding:2px 9px; }
|
|
332
|
+
.cfg-section {
|
|
333
|
+
display:grid; grid-template-columns:220px minmax(0, 1fr); gap:8px 28px;
|
|
334
|
+
padding:18px; border-bottom:1px solid var(--hairline); align-items:start;
|
|
335
|
+
}
|
|
336
|
+
.cfg-title { font-size:13px; font-weight:600; margin-bottom:5px; }
|
|
337
|
+
.cfg-hint { color:var(--faint); font-size:12px; line-height:1.5; padding-right:8px; }
|
|
338
|
+
.cfg-foot { display:flex; gap:12px; align-items:center; padding:14px 18px; }
|
|
339
|
+
@media (max-width: 900px) {
|
|
340
|
+
.cfg-section { grid-template-columns:1fr; }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/* theme swatches */
|
|
344
|
+
.swatches { display:flex; gap:8px; flex-wrap:wrap; }
|
|
345
|
+
.swatch {
|
|
346
|
+
display:flex; align-items:center; gap:9px; padding:6px 13px 6px 7px;
|
|
347
|
+
background:var(--bg); border:1px solid var(--border); border-radius:var(--r);
|
|
348
|
+
font:500 12px var(--sans); color:var(--dim); cursor:pointer;
|
|
349
|
+
}
|
|
350
|
+
.swatch:hover { border-color:var(--dim); color:var(--text); }
|
|
351
|
+
.swatch[aria-pressed="true"] { border-color:var(--accent); color:var(--text); }
|
|
352
|
+
.swatch .sw {
|
|
353
|
+
width:36px; height:24px; border-radius:5px; border:1px solid var(--hairline);
|
|
354
|
+
position:relative; flex:none;
|
|
355
|
+
}
|
|
356
|
+
.swatch .sw i { position:absolute; width:8px; height:8px; border-radius:50%; top:8px; }
|
|
357
|
+
.swatch .sw i:first-child { left:7px; }
|
|
358
|
+
.swatch .sw i:last-child { left:19px; }
|
|
359
|
+
|
|
360
|
+
/* theme editor knobs: gradient tracks preview what each slider does */
|
|
361
|
+
.knobs {
|
|
362
|
+
display:grid; grid-template-columns:92px minmax(120px, 300px) 54px;
|
|
363
|
+
gap:9px 14px; align-items:center;
|
|
364
|
+
}
|
|
365
|
+
.knobs label { color:var(--dim); font-size:12px; text-align:right; }
|
|
366
|
+
.knobs .knob-val { color:var(--faint); font-size:11px; font-family:var(--mono); }
|
|
367
|
+
.knobs select { max-width:120px; }
|
|
368
|
+
.knobs input[type="range"] { -webkit-appearance:none; appearance:none; height:20px; background:transparent; margin:0; }
|
|
369
|
+
.knobs input[type="range"]::-webkit-slider-runnable-track {
|
|
370
|
+
height:8px; border-radius:4px; border:1px solid var(--hairline);
|
|
371
|
+
background:var(--track, var(--surface-2));
|
|
372
|
+
}
|
|
373
|
+
.knobs input[type="range"]::-webkit-slider-thumb {
|
|
374
|
+
-webkit-appearance:none; width:16px; height:16px; border-radius:50%; margin-top:-5px;
|
|
375
|
+
background:var(--text); border:3px solid var(--bg); box-shadow:0 0 0 1px var(--border-strong);
|
|
376
|
+
}
|
|
377
|
+
.knobs input[type="range"]::-moz-range-track {
|
|
378
|
+
height:8px; border-radius:4px; border:1px solid var(--hairline);
|
|
379
|
+
background:var(--track, var(--surface-2));
|
|
380
|
+
}
|
|
381
|
+
.knobs input[type="range"]::-moz-range-thumb {
|
|
382
|
+
width:12px; height:12px; border-radius:50%;
|
|
383
|
+
background:var(--text); border:3px solid var(--bg); box-shadow:0 0 0 1px var(--border-strong);
|
|
384
|
+
}
|
|
385
|
+
.sw.wheel { background:conic-gradient(oklch(0.7 0.15 20), oklch(0.8 0.15 90), oklch(0.75 0.15 150), oklch(0.7 0.15 230), oklch(0.7 0.15 310), oklch(0.7 0.15 20)); }
|
|
386
|
+
.cfg-status { color:var(--green); font-size:12px; margin-left:auto; }
|
|
230
387
|
</style>
|
|
231
388
|
</head>
|
|
232
389
|
<body>
|
|
233
390
|
<header>
|
|
234
391
|
<h1><svg viewBox="0 0 64 64" width="18" height="18" style="vertical-align:-3px; margin-right:2px" aria-hidden="true"><rect x="16" y="44" width="34" height="10" rx="5" fill="#57534b"/><rect x="11" y="29" width="34" height="10" rx="5" fill="#8a8375"/><rect x="6" y="14" width="34" height="10" rx="5" fill="#dfa662"/><circle cx="48" cy="19" r="4.5" fill="#5ec488"/><circle cx="48" cy="19" r="7.5" fill="#5ec488" opacity="0.25"/></svg> stack<span>deck</span></h1>
|
|
235
|
-
<span id="summary"
|
|
392
|
+
<span id="summary">connecting…</span>
|
|
236
393
|
<span class="spacer"></span>
|
|
237
|
-
<button class="ghost" id="hiddenToggle" onclick="toggleHidden()" title="show/hide the items you've hidden"></button>
|
|
394
|
+
<button class="ghost" id="hiddenToggle" style="display:none" onclick="toggleHidden()" title="show/hide the items you've hidden"></button>
|
|
238
395
|
<button class="ghost" onclick="toggleSettings()">settings</button>
|
|
239
396
|
<span class="paths" id="metaPaths"></span>
|
|
240
397
|
</header>
|
|
241
398
|
<main>
|
|
242
399
|
<div class="ledger" id="settings" style="display:none; margin-top:18px">
|
|
243
|
-
<div class="
|
|
244
|
-
<
|
|
245
|
-
<
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
<div class="cfg-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
<button onclick="saveConfig()">Save</button>
|
|
252
|
-
<span class="cmdline" id="cfg-path"></span>
|
|
400
|
+
<div class="cfg-head">
|
|
401
|
+
<span role="heading" aria-level="2">Settings</span>
|
|
402
|
+
<button class="ghost" aria-label="close settings" onclick="toggleSettings()">×</button>
|
|
403
|
+
</div>
|
|
404
|
+
<div class="cfg-section">
|
|
405
|
+
<div class="cfg-info">
|
|
406
|
+
<div class="cfg-title">Scanned folders</div>
|
|
407
|
+
<div class="cfg-hint">Where Stackdeck looks for projects. Tick a subfolder to include it, untick to exclude.</div>
|
|
253
408
|
</div>
|
|
409
|
+
<div class="cfg-body">
|
|
410
|
+
<div id="cfg-roots-list"></div>
|
|
411
|
+
<div id="cfg-browser" style="display:none"></div>
|
|
412
|
+
<button class="ghost" onclick="browseFolders()">+ add folder…</button>
|
|
413
|
+
</div>
|
|
414
|
+
</div>
|
|
415
|
+
<div class="cfg-section">
|
|
416
|
+
<div class="cfg-info">
|
|
417
|
+
<div class="cfg-title">Project categories</div>
|
|
418
|
+
<div class="cfg-hint">The order they appear on the board. Drag, or use the arrows.</div>
|
|
419
|
+
</div>
|
|
420
|
+
<div class="cfg-body">
|
|
421
|
+
<div id="cfg-cats" class="cat-list"></div>
|
|
422
|
+
<div style="display:flex; gap:6px; margin-top:10px">
|
|
423
|
+
<input id="new-cat" placeholder="new category…" aria-label="new category name"
|
|
424
|
+
onkeydown="if (event.key === 'Enter') addCategoryInline()">
|
|
425
|
+
<button class="ghost" onclick="addCategoryInline()">add</button>
|
|
426
|
+
</div>
|
|
427
|
+
</div>
|
|
428
|
+
</div>
|
|
429
|
+
<div class="cfg-section">
|
|
430
|
+
<div class="cfg-info">
|
|
431
|
+
<div class="cfg-title">Theme</div>
|
|
432
|
+
<div class="cfg-hint">Applies immediately and saves on its own. Pick custom to tune every color.</div>
|
|
433
|
+
</div>
|
|
434
|
+
<div class="cfg-body">
|
|
435
|
+
<div class="swatches">
|
|
436
|
+
<button class="swatch" data-tbtn="" onclick="applyTheme('', true)">
|
|
437
|
+
<span class="sw" style="background:oklch(0.145 0.008 75)" aria-hidden="true"><i style="background:oklch(0.78 0.115 68)"></i><i style="background:oklch(0.74 0.145 152)"></i></span>calm</button>
|
|
438
|
+
<button class="swatch" data-tbtn="playful" onclick="applyTheme('playful', true)">
|
|
439
|
+
<span class="sw" style="background:oklch(0.17 0.03 200)" aria-hidden="true"><i style="background:oklch(0.74 0.16 35)"></i><i style="background:oklch(0.78 0.16 155)"></i></span>playful</button>
|
|
440
|
+
<button class="swatch" data-tbtn="austere" onclick="applyTheme('austere', true)">
|
|
441
|
+
<span class="sw" style="background:oklch(0.13 0.003 80)" aria-hidden="true"><i style="background:oklch(0.85 0.02 85)"></i><i style="background:oklch(0.78 0.09 150)"></i></span>austere</button>
|
|
442
|
+
<button class="swatch" data-tbtn="custom" onclick="applyTheme('custom', true)">
|
|
443
|
+
<span class="sw wheel" id="sw-custom" aria-hidden="true"></span>custom…</button>
|
|
444
|
+
</div>
|
|
445
|
+
<div id="theme-editor" style="display:none; margin-top:14px">
|
|
446
|
+
<div class="knobs">
|
|
447
|
+
<label for="tk-hue">base hue</label>
|
|
448
|
+
<input type="range" id="tk-hue" min="0" max="360" step="1" value="75" oninput="knob('hue', this.value)"
|
|
449
|
+
style="--track:linear-gradient(90deg, oklch(0.55 0.06 0), oklch(0.55 0.06 60), oklch(0.55 0.06 120), oklch(0.55 0.06 180), oklch(0.55 0.06 240), oklch(0.55 0.06 300), oklch(0.55 0.06 360))">
|
|
450
|
+
<span class="knob-val" id="tkv-hue">75</span>
|
|
451
|
+
<label for="tk-tint">warmth</label>
|
|
452
|
+
<input type="range" id="tk-tint" min="0" max="0.05" step="0.002" value="0.01" oninput="knob('tint', this.value)"
|
|
453
|
+
style="--track:linear-gradient(90deg, oklch(0.45 0 0), oklch(0.5 0.08 75))">
|
|
454
|
+
<span class="knob-val" id="tkv-tint">0.01</span>
|
|
455
|
+
<label for="tk-bgL">dark ↔ light</label>
|
|
456
|
+
<input type="range" id="tk-bgL" min="0.08" max="0.98" step="0.005" value="0.145" oninput="knob('bgL', this.value)"
|
|
457
|
+
style="--track:linear-gradient(90deg, oklch(0.10 0.01 75), oklch(0.97 0.01 75))">
|
|
458
|
+
<span class="knob-val" id="tkv-bgL">0.145</span>
|
|
459
|
+
<label for="tk-accentHue">accent hue</label>
|
|
460
|
+
<input type="range" id="tk-accentHue" min="0" max="360" step="1" value="68" oninput="knob('accentHue', this.value)"
|
|
461
|
+
style="--track:linear-gradient(90deg, oklch(0.6 0.14 0), oklch(0.6 0.14 60), oklch(0.6 0.14 120), oklch(0.6 0.14 180), oklch(0.6 0.14 240), oklch(0.6 0.14 300), oklch(0.6 0.14 360))">
|
|
462
|
+
<span class="knob-val" id="tkv-accentHue">68</span>
|
|
463
|
+
<label for="tk-accentC">accent pop</label>
|
|
464
|
+
<input type="range" id="tk-accentC" min="0" max="0.25" step="0.005" value="0.115" oninput="knob('accentC', this.value)">
|
|
465
|
+
<span class="knob-val" id="tkv-accentC">0.115</span>
|
|
466
|
+
<label for="tk-radius">corners</label>
|
|
467
|
+
<input type="range" id="tk-radius" min="0" max="18" step="1" value="7" oninput="knob('radius', this.value)">
|
|
468
|
+
<span class="knob-val" id="tkv-radius">7</span>
|
|
469
|
+
<label for="tk-font">font</label>
|
|
470
|
+
<select id="tk-font" onchange="knob('font', this.value)">
|
|
471
|
+
<option value="sans">sans</option>
|
|
472
|
+
<option value="mono">mono</option>
|
|
473
|
+
</select>
|
|
474
|
+
<span></span>
|
|
475
|
+
</div>
|
|
476
|
+
<button class="ghost" style="margin-top:10px" onclick="resetKnobs()">reset to calm defaults</button>
|
|
477
|
+
</div>
|
|
478
|
+
</div>
|
|
479
|
+
</div>
|
|
480
|
+
<div class="cfg-foot">
|
|
481
|
+
<span class="cmdline">changes apply immediately and save to <span id="cfg-path"></span></span>
|
|
482
|
+
<span class="cfg-status" id="cfg-status" role="status" aria-live="polite"></span>
|
|
254
483
|
</div>
|
|
255
484
|
</div>
|
|
256
|
-
<div class="section-label">Services <span class="count" id="svc-count"></span>
|
|
485
|
+
<div class="section-label"><span role="heading" aria-level="2">Services</span> <span class="count" id="svc-count"></span>
|
|
257
486
|
<span style="margin-left:auto; display:flex; gap:4px">
|
|
258
487
|
<button class="ghost" onclick="collapseAll('g', true)">collapse all</button>
|
|
259
488
|
<button class="ghost" onclick="collapseAll('g', false)">expand all</button>
|
|
@@ -262,25 +491,145 @@
|
|
|
262
491
|
</div>
|
|
263
492
|
<div id="services"></div>
|
|
264
493
|
|
|
265
|
-
<div class="section-label">Dev tools <span class="count" id="infra-count"></span></div>
|
|
494
|
+
<div class="section-label"><span role="heading" aria-level="2">Dev tools</span> <span class="count" id="infra-count"></span></div>
|
|
266
495
|
<div class="ledger" id="infra"></div>
|
|
267
496
|
|
|
268
497
|
<div class="section-label">
|
|
269
|
-
Projects <span class="count" id="proj-count"></span>
|
|
498
|
+
<span role="heading" aria-level="2">Projects</span> <span class="count" id="proj-count"></span>
|
|
270
499
|
<span style="margin-left:auto; display:flex; gap:4px; align-items:center">
|
|
271
500
|
<button class="ghost" onclick="addCategory()">+ category</button>
|
|
272
501
|
<button class="ghost" onclick="refreshProjects()" title="rescan project roots for new folders">↻ refresh</button>
|
|
273
502
|
<button class="ghost" onclick="collapseAll('c', true)">collapse all</button>
|
|
274
503
|
<button class="ghost" onclick="collapseAll('c', false)">expand all</button>
|
|
275
|
-
<input id="filter" type="search" placeholder="filter projects…" oninput="
|
|
504
|
+
<input id="filter" type="search" placeholder="filter projects…" aria-label="filter projects" oninput="clearTimeout(window.__fdeb); window.__fdeb=setTimeout(renderProjects,120)">
|
|
276
505
|
</span>
|
|
277
506
|
</div>
|
|
278
507
|
<div id="projects"></div>
|
|
279
508
|
</main>
|
|
280
|
-
<div class="toast" id="toast"></div>
|
|
509
|
+
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
|
281
510
|
|
|
282
511
|
<script>
|
|
283
512
|
const TOKEN = "__STACKDECK_TOKEN__"; // injected by the daemon when serving this page
|
|
513
|
+
/* ---------- themes ----------
|
|
514
|
+
Presets: "" (calm), playful, austere. "custom" derives every token from a
|
|
515
|
+
few knobs (works light or dark). Persisted in config.json via /api/config;
|
|
516
|
+
localStorage is only a flash-free cache. ?theme= in the URL wins. */
|
|
517
|
+
const THEME_KNOB_DEFAULTS = { hue: 75, tint: 0.01, bgL: 0.145, accentHue: 68, accentC: 0.115, radius: 7, font: "sans" };
|
|
518
|
+
let themeKnobs = { ...THEME_KNOB_DEFAULTS };
|
|
519
|
+
let themeName = "", themeLocked = false;
|
|
520
|
+
|
|
521
|
+
function applyTokens(k) {
|
|
522
|
+
const dark = k.bgL < 0.5;
|
|
523
|
+
const st = document.documentElement.style;
|
|
524
|
+
const o = (l, c, h, a) => `oklch(${l.toFixed(3)} ${Math.max(0, c).toFixed(3)} ${h}${a ? " / " + a : ""})`;
|
|
525
|
+
const n = k.tint, h = k.hue;
|
|
526
|
+
st.setProperty("--bg", o(k.bgL, n, h));
|
|
527
|
+
if (dark) {
|
|
528
|
+
st.setProperty("--surface", o(k.bgL + 0.035, n, h));
|
|
529
|
+
st.setProperty("--surface-2", o(k.bgL + 0.07, n + 0.002, h));
|
|
530
|
+
st.setProperty("--border", o(k.bgL + 0.125, n + 0.004, h));
|
|
531
|
+
st.setProperty("--hairline", o(k.bgL + 0.085, n + 0.002, h));
|
|
532
|
+
st.setProperty("--text", o(0.93, Math.min(n, 0.01), h + 10));
|
|
533
|
+
st.setProperty("--dim", o(0.70, n + 0.004, h + 5));
|
|
534
|
+
st.setProperty("--faint", o(0.60, n + 0.002, h + 5));
|
|
535
|
+
st.setProperty("--border-strong", o(0.52, n + 0.002, h));
|
|
536
|
+
st.setProperty("--accent", o(0.78, k.accentC, k.accentHue));
|
|
537
|
+
st.setProperty("--accent-dim", o(0.78, k.accentC, k.accentHue, ".14"));
|
|
538
|
+
st.setProperty("--green", o(0.74, 0.145, 152));
|
|
539
|
+
st.setProperty("--green-dim", o(0.74, 0.145, 152, ".13"));
|
|
540
|
+
st.setProperty("--red", o(0.66, 0.17, 26));
|
|
541
|
+
st.setProperty("--red-dim", o(0.66, 0.17, 26, ".13"));
|
|
542
|
+
} else {
|
|
543
|
+
st.setProperty("--surface", o(Math.min(0.99, k.bgL + 0.015), n, h));
|
|
544
|
+
st.setProperty("--surface-2", o(k.bgL - 0.05, n + 0.002, h));
|
|
545
|
+
st.setProperty("--border", o(k.bgL - 0.16, n + 0.004, h));
|
|
546
|
+
st.setProperty("--hairline", o(k.bgL - 0.08, n + 0.002, h));
|
|
547
|
+
st.setProperty("--text", o(0.25, Math.min(n, 0.01), h + 10));
|
|
548
|
+
st.setProperty("--dim", o(0.47, n + 0.004, h + 5));
|
|
549
|
+
st.setProperty("--faint", o(0.55, n + 0.002, h + 5));
|
|
550
|
+
st.setProperty("--border-strong", o(0.62, n + 0.002, h));
|
|
551
|
+
st.setProperty("--accent", o(0.53, Math.max(k.accentC, 0.05), k.accentHue));
|
|
552
|
+
st.setProperty("--accent-dim", o(0.53, Math.max(k.accentC, 0.05), k.accentHue, ".14"));
|
|
553
|
+
st.setProperty("--green", o(0.50, 0.13, 152));
|
|
554
|
+
st.setProperty("--green-dim", o(0.50, 0.13, 152, ".12"));
|
|
555
|
+
st.setProperty("--red", o(0.53, 0.17, 26));
|
|
556
|
+
st.setProperty("--red-dim", o(0.53, 0.17, 26, ".12"));
|
|
557
|
+
}
|
|
558
|
+
st.setProperty("--r", Math.round(k.radius) + "px");
|
|
559
|
+
st.setProperty("--r-sm", Math.max(2, Math.round(k.radius * 0.7)) + "px");
|
|
560
|
+
st.setProperty("--r-lg", Math.round(k.radius * 1.45) + "px");
|
|
561
|
+
st.colorScheme = dark ? "dark" : "light";
|
|
562
|
+
document.body.style.fontFamily = k.font === "mono" ? "var(--mono)" : "";
|
|
563
|
+
}
|
|
564
|
+
function clearTokens() {
|
|
565
|
+
const st = document.documentElement.style;
|
|
566
|
+
[...st].filter(p => p.startsWith("--")).forEach(p => st.removeProperty(p));
|
|
567
|
+
st.removeProperty("color-scheme");
|
|
568
|
+
if (document.body) document.body.style.fontFamily = "";
|
|
569
|
+
}
|
|
570
|
+
function markPressed(t) {
|
|
571
|
+
document.querySelectorAll("[data-tbtn]").forEach(b => b.setAttribute("aria-pressed", String(b.dataset.tbtn === t)));
|
|
572
|
+
}
|
|
573
|
+
function syncEditor() {
|
|
574
|
+
for (const k of ["hue", "tint", "bgL", "accentHue", "accentC", "radius"]) {
|
|
575
|
+
const el = document.getElementById("tk-" + k);
|
|
576
|
+
if (el) el.value = themeKnobs[k];
|
|
577
|
+
const v = document.getElementById("tkv-" + k);
|
|
578
|
+
if (v) v.textContent = themeKnobs[k];
|
|
579
|
+
}
|
|
580
|
+
const f = document.getElementById("tk-font");
|
|
581
|
+
if (f) f.value = themeKnobs.font;
|
|
582
|
+
updateAccentTrack();
|
|
583
|
+
}
|
|
584
|
+
function applyTheme(t, save) {
|
|
585
|
+
themeName = t;
|
|
586
|
+
if (t === "custom") { delete document.documentElement.dataset.theme; applyTokens(themeKnobs); }
|
|
587
|
+
else {
|
|
588
|
+
clearTokens();
|
|
589
|
+
if (t) document.documentElement.dataset.theme = t;
|
|
590
|
+
else delete document.documentElement.dataset.theme;
|
|
591
|
+
}
|
|
592
|
+
markPressed(t);
|
|
593
|
+
const ed = document.getElementById("theme-editor");
|
|
594
|
+
if (ed) ed.style.display = t === "custom" ? "" : "none";
|
|
595
|
+
const chip = document.getElementById("sw-custom");
|
|
596
|
+
if (chip) {
|
|
597
|
+
if (t === "custom") {
|
|
598
|
+
const dk = themeKnobs.bgL < 0.5;
|
|
599
|
+
chip.style.background = `oklch(${themeKnobs.bgL} ${themeKnobs.tint} ${themeKnobs.hue})`;
|
|
600
|
+
chip.innerHTML = `<i style="background:oklch(${dk ? 0.78 : 0.53} ${themeKnobs.accentC} ${themeKnobs.accentHue})"></i>` +
|
|
601
|
+
`<i style="background:oklch(${dk ? 0.74 : 0.50} 0.14 152)"></i>`;
|
|
602
|
+
} else { chip.style.background = ""; chip.innerHTML = ""; }
|
|
603
|
+
}
|
|
604
|
+
try { localStorage.setItem("stackdeck.themeCache", JSON.stringify({ t, k: themeKnobs })); } catch {}
|
|
605
|
+
if (save) {
|
|
606
|
+
clearTimeout(applyTheme.t);
|
|
607
|
+
applyTheme.t = setTimeout(() =>
|
|
608
|
+
api("/api/config", { theme: t, themeTokens: themeKnobs }).catch(e => toast(e.message, true)), 300);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
function updateAccentTrack() {
|
|
612
|
+
const el = document.getElementById("tk-accentC");
|
|
613
|
+
if (el) el.style.setProperty("--track",
|
|
614
|
+
`linear-gradient(90deg, oklch(0.6 0 ${themeKnobs.accentHue}), oklch(0.6 0.25 ${themeKnobs.accentHue}))`);
|
|
615
|
+
}
|
|
616
|
+
function knob(k, v) {
|
|
617
|
+
themeKnobs[k] = k === "font" ? v : Number(v);
|
|
618
|
+
const disp = document.getElementById("tkv-" + k);
|
|
619
|
+
if (disp) disp.textContent = v;
|
|
620
|
+
if (k === "accentHue") updateAccentTrack();
|
|
621
|
+
applyTheme("custom", true);
|
|
622
|
+
}
|
|
623
|
+
function resetKnobs() { themeKnobs = { ...THEME_KNOB_DEFAULTS }; syncEditor(); applyTheme("custom", true); }
|
|
624
|
+
{
|
|
625
|
+
// flash-free boot: cached theme first, server truth follows via /api/meta
|
|
626
|
+
try {
|
|
627
|
+
const c = JSON.parse(localStorage.getItem("stackdeck.themeCache") || "null");
|
|
628
|
+
if (c) { themeKnobs = { ...THEME_KNOB_DEFAULTS, ...(c.k || {}) }; applyTheme(c.t || "", false); }
|
|
629
|
+
} catch {}
|
|
630
|
+
const u = new URLSearchParams(location.search).get("theme");
|
|
631
|
+
if (/^[a-z]+$/.test(u || "")) { themeLocked = true; applyTheme(u === "calm" ? "" : u, false); }
|
|
632
|
+
}
|
|
284
633
|
const $svc = document.getElementById("services");
|
|
285
634
|
const $proj = document.getElementById("projects");
|
|
286
635
|
const $toast = document.getElementById("toast");
|
|
@@ -305,9 +654,10 @@ function collapseAll(prefix, on) {
|
|
|
305
654
|
prefix === "g" ? renderServices() : renderProjects();
|
|
306
655
|
}
|
|
307
656
|
function chevTitle(key, label, count) {
|
|
308
|
-
return `<
|
|
309
|
-
|
|
310
|
-
<span class="
|
|
657
|
+
return `<button class="title" aria-expanded="${!collapsed.has(key)}"
|
|
658
|
+
onclick="toggleCollapse(decodeURIComponent('${encodeURIComponent(key)}'))">
|
|
659
|
+
<span class="chev ${collapsed.has(key) ? "closed" : ""}" aria-hidden="true">▾</span>${label}
|
|
660
|
+
<span class="count">${count}</span></button>`;
|
|
311
661
|
}
|
|
312
662
|
|
|
313
663
|
function toggleHidden() { showHidden = !showHidden; renderServices(); renderProjects(); updateHiddenToggle(); }
|
|
@@ -377,25 +727,28 @@ function svcRow(s) {
|
|
|
377
727
|
// Dirty (uncommitted changes) shows as an amber outline on the dropdown —
|
|
378
728
|
// a badge would shift the columns.
|
|
379
729
|
const selTitle = s.running
|
|
380
|
-
? `on ${s.branch}
|
|
381
|
-
: (s.dirty ? "uncommitted changes
|
|
730
|
+
? `on ${s.branch}. Kill first to switch the main checkout, or ⧉ runs the selected branch in a parallel worktree${s.dirty ? " · uncommitted changes" : ""}`
|
|
731
|
+
: (s.dirty ? "uncommitted changes: commit or stash to switch branch" : "branch to run (⧉ runs it in a parallel worktree)");
|
|
382
732
|
const branch = `<span class="branch-slot">${s.isGit
|
|
383
|
-
? `<select id="br-${s.name}" class="${s.dirty ? "dirty" : ""}" title="${esc(selTitle)}">
|
|
733
|
+
? `<select id="br-${s.name}" class="${s.dirty ? "dirty" : ""}" title="${esc(selTitle)}" aria-label="branch for ${esc(s.name)}">
|
|
384
734
|
${s.branches.map(b => `<option ${b===s.branch?"selected":""}>${esc(b)}</option>`).join("")}
|
|
385
735
|
</select>
|
|
386
|
-
<button class="ghost" style="padding:3px 7px"
|
|
736
|
+
<button class="ghost" style="padding:3px 7px" aria-label="run the selected branch of ${esc(s.name)} in a parallel worktree"
|
|
737
|
+
title="run the selected branch in a parallel worktree (own checkout, auto $PORT)"
|
|
387
738
|
onclick="worktreeStart('${esc(s.name)}')">⧉</button>`
|
|
388
739
|
: '<span class="badge plain">no git</span>'}</span>`;
|
|
389
740
|
return `
|
|
390
741
|
<div class="row ${s.hidden ? "hiddenItem" : ""}" data-name="${esc(s.name)}"
|
|
391
742
|
ondragover="rowOver(event)" ondragleave="rowLeave(event)" ondrop="rowDrop(event)">
|
|
392
743
|
<div class="row-main">
|
|
393
|
-
<span class="handle"
|
|
744
|
+
<span class="handle" id="h-${s.name}" tabindex="0" role="button" draggable="true"
|
|
745
|
+
aria-label="reorder ${esc(s.name)}: arrow keys move it within and between sections"
|
|
746
|
+
title="drag to reorder or move to a section · or focus and use arrow keys"
|
|
394
747
|
ondragstart="dragStart(event,'${esc(s.name)}')" ondragend="dragEnd()">⠿</span>
|
|
395
|
-
<span class="dot ${s.running ? "up" : ""}"></span>
|
|
748
|
+
<span class="dot ${s.running ? "up" : ""}" role="img" aria-label="${s.running ? "running" : "stopped"}"></span>
|
|
396
749
|
<span class="svc-name">${esc(s.name)}</span>
|
|
397
750
|
${s.resurrected
|
|
398
|
-
? '<span class="badge crash" title="Stackdeck killed it, but a supervisor (brew services / launchd / systemd) restarted it
|
|
751
|
+
? '<span class="badge crash" title="Stackdeck killed it, but a supervisor (brew services / launchd / systemd) restarted it; stop it with its own manager, e.g. brew services stop">auto-restarted</span>'
|
|
399
752
|
: s.running && !s.managed ? '<span class="badge ext" title="running, but not started from Stackdeck">external</span>' : ""}
|
|
400
753
|
${s.restartPending ? '<span class="badge dirty" title="crashed; auto-restart scheduled">restarting…</span>' : ""}
|
|
401
754
|
${!s.running && !s.restartPending && s.lastExit && !s.lastExit.expected && s.lastExit.code !== 0
|
|
@@ -408,30 +761,37 @@ function svcRow(s) {
|
|
|
408
761
|
${branch}
|
|
409
762
|
<span class="row-actions">
|
|
410
763
|
${s.running
|
|
411
|
-
? `<button class="kill primary" onclick="stop('${s.name}')">Kill</button>`
|
|
412
|
-
: `<button class="start primary" onclick="start('${s.name}')">Start</button>`}
|
|
764
|
+
? `<button class="kill primary" onclick="stop('${esc(s.name)}', this)">Kill</button>`
|
|
765
|
+
: `<button class="start primary" onclick="start('${esc(s.name)}', false, this)">Start</button>`}
|
|
413
766
|
<button class="ghost" ${s.running ? "" : "disabled"} title="stop, then start again on the same branch"
|
|
414
767
|
onclick="restart('${s.name}')">restart</button>
|
|
415
|
-
<button class="ghost"
|
|
416
|
-
|
|
768
|
+
<button class="ghost" aria-expanded="${logOpen.has(s.name)}" aria-controls="logwrap-${s.name}"
|
|
769
|
+
onclick="toggleLogs('${s.name}', this)">logs</button>
|
|
770
|
+
<button class="ghost" aria-expanded="${open.has(s.name)}" aria-controls="edit-${s.name}"
|
|
771
|
+
onclick="toggleEdit('${s.name}', this)">edit</button>
|
|
417
772
|
<button class="ghost" title="${s.hidden ? "show on board" : "hide from board"}"
|
|
418
773
|
onclick="hideItem('service','${esc(s.name)}',${!s.hidden})">${s.hidden ? "unhide" : "hide"}</button>
|
|
419
774
|
</span>
|
|
420
775
|
</div>
|
|
421
776
|
<div class="drawer ${open.has(s.name) ? "open" : ""}" id="edit-${s.name}">
|
|
422
777
|
<div class="fields">
|
|
423
|
-
<label>name</label><input id="name-${s.name}" value="${esc(s.name)}">
|
|
424
|
-
<label>dir</label><input id="dir-${s.name}" value="${esc(s.dir)}">
|
|
425
|
-
<label>command</label><textarea id="cmd-${s.name}">${esc(s.command)}</textarea>
|
|
426
|
-
<label>port</label><input id="port-${s.name}" value="${s.port ?? ""}" placeholder="for status detection">
|
|
427
|
-
<label>env</label><textarea id="env-${s.name}" placeholder='{"KEY":"value"}
|
|
428
|
-
<label>restart</label>
|
|
778
|
+
<label for="name-${s.name}">name</label><input id="name-${s.name}" value="${esc(s.name)}">
|
|
779
|
+
<label for="dir-${s.name}">dir</label><input id="dir-${s.name}" value="${esc(s.dir)}">
|
|
780
|
+
<label for="cmd-${s.name}">command</label><textarea id="cmd-${s.name}">${esc(s.command)}</textarea>
|
|
781
|
+
<label for="port-${s.name}">port</label><input id="port-${s.name}" value="${s.port ?? ""}" placeholder="for status detection">
|
|
782
|
+
<label for="env-${s.name}">env</label><textarea id="env-${s.name}" placeholder='{"KEY":"value"} (.env in dir loads automatically; these override it)'>${esc(JSON.stringify(s.env || {}))}</textarea>
|
|
783
|
+
<label for="rs-${s.name}">restart</label>
|
|
429
784
|
<select id="rs-${s.name}" style="max-width:160px">
|
|
430
785
|
<option value="">never (default)</option>
|
|
431
786
|
<option value="on-failure" ${s.restart === "on-failure" ? "selected" : ""}>on failure (backoff, 5 tries)</option>
|
|
432
787
|
</select>
|
|
433
|
-
<label
|
|
434
|
-
<
|
|
788
|
+
<label for="grp-${s.name}">section</label>
|
|
789
|
+
<select id="grp-${s.name}" style="max-width:160px">
|
|
790
|
+
<option value="" ${!s.group ? "selected" : ""}>Ungrouped</option>
|
|
791
|
+
${groups.map(g => `<option ${s.group === g ? "selected" : ""}>${esc(g)}</option>`).join("")}
|
|
792
|
+
</select>
|
|
793
|
+
<label for="dep-${s.name}">depends on</label><input id="dep-${s.name}" value="${esc((s.dependsOn || []).join(", "))}" placeholder="comma-separated service names, started first">
|
|
794
|
+
<label for="rw-${s.name}">ready when</label><input id="rw-${s.name}" value="${esc((s.readyWhen && s.readyWhen.log) || "")}" placeholder="regex matched against its logs (default: port opens)">
|
|
435
795
|
<label></label>
|
|
436
796
|
<div style="display:flex; gap:8px">
|
|
437
797
|
<button onclick="saveSvc('${s.name}')">Save</button>
|
|
@@ -440,8 +800,9 @@ function svcRow(s) {
|
|
|
440
800
|
</div>
|
|
441
801
|
</div>
|
|
442
802
|
<div class="drawer ${logOpen.has(s.name) ? "open" : ""}" id="logwrap-${s.name}">
|
|
443
|
-
<input class="logfilter" id="lf-${s.name}" type="search" placeholder="filter logs…"
|
|
444
|
-
|
|
803
|
+
<input class="logfilter" id="lf-${s.name}" type="search" placeholder="filter logs…"
|
|
804
|
+
aria-label="filter ${esc(s.name)} logs" oninput="filterLogsDeb('${s.name}')">
|
|
805
|
+
<div class="logs" id="log-${s.name}" role="log" aria-label="${esc(s.name)} logs"></div>
|
|
445
806
|
</div>
|
|
446
807
|
</div>`;
|
|
447
808
|
}
|
|
@@ -566,24 +927,43 @@ async function delSection(name) {
|
|
|
566
927
|
|
|
567
928
|
/* ---------- projects ---------- */
|
|
568
929
|
|
|
930
|
+
function catChoices() {
|
|
931
|
+
const extra = [...new Set(projects.map(p => p.category).filter(Boolean))]
|
|
932
|
+
.filter(c => !CAT_ORDER.includes(c)).sort();
|
|
933
|
+
return [...CAT_ORDER.filter(c => c !== "Uncategorized"), ...extra, "Uncategorized"];
|
|
934
|
+
}
|
|
935
|
+
async function setProjCat(name, cat) {
|
|
936
|
+
try {
|
|
937
|
+
await api("/api/project/category", { name, category: cat || null });
|
|
938
|
+
toast(`${name} → ${cat || "Uncategorized"}`);
|
|
939
|
+
} catch (e) { toast(e.message, true); }
|
|
940
|
+
await loadMeta();
|
|
941
|
+
loadProjects(true);
|
|
942
|
+
}
|
|
569
943
|
function projRow(p) {
|
|
570
944
|
const nArg = `decodeURIComponent('${encodeURIComponent(p.name)}')`;
|
|
571
945
|
const branch = p.isGit
|
|
572
946
|
? `<span class="badge git" title="current branch">${esc(p.branch || "?")}</span>`
|
|
573
947
|
: '<span class="badge plain">no git</span>';
|
|
574
948
|
const cmd = p.procs && p.procs.length
|
|
575
|
-
? `<span class="grow cmdline"><b>${p.procs.length} processes</b> · ${esc(p.procs.map(x => x.name).join(", ")
|
|
949
|
+
? `<span class="grow cmdline"><b>${p.procs.length} processes</b> · ${esc(p.procs.map(x => x.name).join(", ").slice(0, 80))}</span>`
|
|
576
950
|
: p.suggestedCommand
|
|
577
951
|
? `<span class="grow cmdline"><b>${esc(p.suggestedCommand)}</b></span>`
|
|
578
952
|
: '<span class="grow cmdline">no run command detected</span>';
|
|
953
|
+
const catSel = catChoices().length > 1
|
|
954
|
+
? `<select class="cat-sel" aria-label="category for ${esc(p.name)}" onchange="setProjCat(${nArg}, this.value)">
|
|
955
|
+
${catChoices().map(c => `<option value="${esc(c === "Uncategorized" ? "" : c)}"
|
|
956
|
+
${(p.category || "Uncategorized") === c ? "selected" : ""}>${esc(c)}</option>`).join("")}
|
|
957
|
+
</select>` : "";
|
|
579
958
|
return `
|
|
580
959
|
<div class="row">
|
|
581
960
|
<div class="row-main">
|
|
582
|
-
<span class="handle" draggable="true" title="drag into one of your categories"
|
|
961
|
+
<span class="handle" tabindex="-1" aria-hidden="true" draggable="true" title="drag into one of your categories"
|
|
583
962
|
ondragstart="projDragStart(event, ${nArg})" ondragend="projDragEnd()">⠿</span>
|
|
584
963
|
<span class="svc-name" style="min-width:200px">${esc(p.name)}</span>
|
|
585
964
|
${branch}
|
|
586
965
|
${cmd}
|
|
966
|
+
${catSel}
|
|
587
967
|
${p.configured
|
|
588
968
|
? '<span class="badge cfgd">configured</span>'
|
|
589
969
|
: `<button class="add" onclick="configure(${nArg})">configure</button>`}
|
|
@@ -709,7 +1089,7 @@ async function configure(name) {
|
|
|
709
1089
|
toast(`${svcName} added to Services${p.suggestedCommand ? "" : " — set its command"}`);
|
|
710
1090
|
open.add(svcName);
|
|
711
1091
|
await load();
|
|
712
|
-
document.getElementById("edit-" + svcName)?.scrollIntoView({ block:"center", behavior:"smooth" });
|
|
1092
|
+
document.getElementById("edit-" + svcName)?.scrollIntoView({ block:"center", behavior: REDUCED ? "auto" : "smooth" });
|
|
713
1093
|
} catch (e) { toast(e.message, true); }
|
|
714
1094
|
}
|
|
715
1095
|
|
|
@@ -719,9 +1099,9 @@ function instanceRow(i) {
|
|
|
719
1099
|
return `
|
|
720
1100
|
<div class="row">
|
|
721
1101
|
<div class="row-main" style="padding-left:38px">
|
|
722
|
-
<span class="dot up"></span>
|
|
1102
|
+
<span class="dot up" role="img" aria-label="running"></span>
|
|
723
1103
|
<span class="svc-name" style="font-weight:500">⧉ ${esc(i.svc)} <span class="cmdline">@ ${esc(i.branch)}</span></span>
|
|
724
|
-
<span class="badge
|
|
1104
|
+
<span class="badge wt" title="parallel worktree instance">worktree</span>
|
|
725
1105
|
${meta && meta.proxyPort && i.port
|
|
726
1106
|
? `<a class="badge link" target="_blank" href="http://${esc(i.key.split("@")[1])}.${esc(i.svc)}.localhost${meta.proxyPort === 80 ? "" : ":" + meta.proxyPort}"
|
|
727
1107
|
title="via the *.localhost proxy">${esc(i.key.split("@")[1])}.${esc(i.svc)}.localhost</a>` : ""}
|
|
@@ -729,12 +1109,14 @@ function instanceRow(i) {
|
|
|
729
1109
|
<span class="grow"></span>
|
|
730
1110
|
<span class="row-actions">
|
|
731
1111
|
<button class="kill primary" onclick="stopInstance('${esc(i.key)}')">Kill</button>
|
|
732
|
-
<button class="ghost"
|
|
1112
|
+
<button class="ghost" aria-expanded="${logOpen.has(i.key)}" aria-controls="logwrap-${i.key}"
|
|
1113
|
+
onclick="toggleLogs('${esc(i.key)}', this)">logs</button>
|
|
733
1114
|
</span>
|
|
734
1115
|
</div>
|
|
735
1116
|
<div class="drawer ${logOpen.has(i.key) ? "open" : ""}" id="logwrap-${i.key}">
|
|
736
|
-
<input class="logfilter" id="lf-${i.key}" type="search" placeholder="filter logs…"
|
|
737
|
-
|
|
1117
|
+
<input class="logfilter" id="lf-${i.key}" type="search" placeholder="filter logs…"
|
|
1118
|
+
aria-label="filter ${esc(i.key)} logs" oninput="filterLogsDeb('${esc(i.key)}')">
|
|
1119
|
+
<div class="logs" id="log-${i.key}" role="log" aria-label="${esc(i.key)} logs"></div>
|
|
738
1120
|
</div>
|
|
739
1121
|
</div>`;
|
|
740
1122
|
}
|
|
@@ -757,8 +1139,9 @@ async function stopInstance(key) {
|
|
|
757
1139
|
setTimeout(() => loadServices(true), 400);
|
|
758
1140
|
}
|
|
759
1141
|
|
|
760
|
-
async function start(name, force) {
|
|
1142
|
+
async function start(name, force, btn) {
|
|
761
1143
|
const sel = document.getElementById("br-" + name);
|
|
1144
|
+
if (btn) { btn.disabled = true; btn.textContent = "starting…"; }
|
|
762
1145
|
try {
|
|
763
1146
|
const s = services.find(x => x.name === name);
|
|
764
1147
|
// when stopped, the dropdown choice is a checkout request; when it was
|
|
@@ -769,18 +1152,20 @@ async function start(name, force) {
|
|
|
769
1152
|
} catch (e) {
|
|
770
1153
|
const busy = e.message.match(/port (\d+) is busy \(pid (\d+)/);
|
|
771
1154
|
if (busy && !force && confirm(`Port ${busy[1]} is used by another process (pid ${busy[2]}).\nKill it and start ${name}?`))
|
|
772
|
-
return start(name, true);
|
|
1155
|
+
return start(name, true, btn);
|
|
773
1156
|
toast(e.message, true);
|
|
1157
|
+
if (btn) { btn.disabled = false; btn.textContent = "Start"; }
|
|
774
1158
|
}
|
|
775
1159
|
loadServices(true);
|
|
776
1160
|
}
|
|
777
|
-
async function stop(name) {
|
|
1161
|
+
async function stop(name, btn) {
|
|
1162
|
+
if (btn) { btn.disabled = true; btn.textContent = "killing…"; }
|
|
778
1163
|
try {
|
|
779
1164
|
const r = await api("/api/stop", { name });
|
|
780
1165
|
toast(r.note || name + " killed", !!r.note);
|
|
781
1166
|
}
|
|
782
|
-
catch (e) { toast(e.message, true); }
|
|
783
|
-
setTimeout(loadServices, 400);
|
|
1167
|
+
catch (e) { toast(e.message, true); if (btn) { btn.disabled = false; btn.textContent = "Kill"; } }
|
|
1168
|
+
setTimeout(() => loadServices(true), 400);
|
|
784
1169
|
}
|
|
785
1170
|
async function restart(name) {
|
|
786
1171
|
toast(name + " restarting…");
|
|
@@ -788,19 +1173,21 @@ async function restart(name) {
|
|
|
788
1173
|
catch (e) { toast(e.message, true); }
|
|
789
1174
|
loadServices(true);
|
|
790
1175
|
}
|
|
791
|
-
function toggleEdit(name) {
|
|
1176
|
+
function toggleEdit(name, btn) {
|
|
792
1177
|
open.has(name) ? open.delete(name) : open.add(name);
|
|
793
1178
|
document.getElementById("edit-" + name).classList.toggle("open", open.has(name));
|
|
1179
|
+
btn?.setAttribute("aria-expanded", open.has(name));
|
|
794
1180
|
}
|
|
795
|
-
function toggleLogs(name) {
|
|
1181
|
+
function toggleLogs(name, btn) {
|
|
796
1182
|
if (logOpen.has(name)) { logOpen.delete(name); streams[name]?.close(); delete streams[name]; }
|
|
797
1183
|
else logOpen.add(name);
|
|
798
1184
|
document.getElementById("logwrap-" + name).classList.toggle("open", logOpen.has(name));
|
|
1185
|
+
btn?.setAttribute("aria-expanded", logOpen.has(name));
|
|
799
1186
|
if (logOpen.has(name)) attachStream(name);
|
|
800
1187
|
}
|
|
801
1188
|
/* Minimal ANSI SGR → HTML (colors 30–37/90–97, bold, dim, reset). */
|
|
802
|
-
const ANSI_FG = { 30:"#
|
|
803
|
-
90:"#
|
|
1189
|
+
const ANSI_FG = { 30:"#8f897e",31:"#e06c5f",32:"#84c17b",33:"#d9a05b",34:"#7aa2d4",35:"#c092d0",36:"#72bcb8",37:"#b8b2a7",
|
|
1190
|
+
90:"#a09a8f",91:"#f08d80",92:"#a0d598",93:"#e8bc7e",94:"#9dbfe6",95:"#d5b0e2",96:"#93d5d1",97:"#d6d0c4" };
|
|
804
1191
|
function ansiToHtml(raw) {
|
|
805
1192
|
let out = "", openSpan = false;
|
|
806
1193
|
for (const part of raw.split(/(\x1b\[[0-9;]*m)/)) {
|
|
@@ -824,6 +1211,8 @@ function logMatches(name, line) {
|
|
|
824
1211
|
const q = (document.getElementById("lf-" + name)?.value || "").toLowerCase();
|
|
825
1212
|
return !q || line.toLowerCase().includes(q);
|
|
826
1213
|
}
|
|
1214
|
+
const flDeb = Object.create(null);
|
|
1215
|
+
function filterLogsDeb(name) { clearTimeout(flDeb[name]); flDeb[name] = setTimeout(() => filterLogs(name), 120); }
|
|
827
1216
|
function filterLogs(name) {
|
|
828
1217
|
const pane = document.getElementById("log-" + name);
|
|
829
1218
|
if (!pane) return;
|
|
@@ -833,21 +1222,29 @@ function filterLogs(name) {
|
|
|
833
1222
|
pane.scrollTop = pane.scrollHeight;
|
|
834
1223
|
}
|
|
835
1224
|
function attachStream(name) {
|
|
836
|
-
|
|
1225
|
+
// A live stream survives re-renders: the pane div is recreated empty, so
|
|
1226
|
+
// just repaint it from the client buffer instead of reconnecting the SSE.
|
|
1227
|
+
if (streams[name]) {
|
|
1228
|
+
const pane = document.getElementById("log-" + name);
|
|
1229
|
+
if (pane && !pane.childNodes.length) filterLogs(name);
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
837
1232
|
const pane = document.getElementById("log-" + name);
|
|
838
1233
|
if (!pane) return;
|
|
839
1234
|
pane.innerHTML = "";
|
|
840
|
-
clientLogs[name] = [];
|
|
1235
|
+
clientLogs[name] = []; // fresh connection: the server replays its buffer
|
|
841
1236
|
const es = new EventSource("/api/logs?name=" + encodeURIComponent(name) + "&t=" + TOKEN);
|
|
842
1237
|
es.onmessage = (ev) => {
|
|
843
1238
|
const line = JSON.parse(ev.data);
|
|
844
1239
|
const buf = clientLogs[name];
|
|
845
1240
|
buf.push(line);
|
|
846
1241
|
if (buf.length > 3000) buf.shift();
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
1242
|
+
const p = document.getElementById("log-" + name); // never a stale node
|
|
1243
|
+
if (!p || !logMatches(name, line)) return;
|
|
1244
|
+
const stick = p.scrollHeight - p.scrollTop - p.clientHeight < 40;
|
|
1245
|
+
p.insertAdjacentHTML("beforeend", ansiToHtml(line) + "\n");
|
|
1246
|
+
while (p.childNodes.length > 6000) p.removeChild(p.firstChild);
|
|
1247
|
+
if (stick) p.scrollTop = p.scrollHeight; // don't yank the view while reading history
|
|
851
1248
|
};
|
|
852
1249
|
streams[name] = es;
|
|
853
1250
|
}
|
|
@@ -884,6 +1281,9 @@ async function saveSvc(name) {
|
|
|
884
1281
|
JSON.stringify(body.dependsOn) !== JSON.stringify(cur.dependsOn || []) ||
|
|
885
1282
|
rw !== ((cur.readyWhen && cur.readyWhen.log) || "");
|
|
886
1283
|
if (fieldsChanged) await api("/api/service", body);
|
|
1284
|
+
const wantedGroup = document.getElementById("grp-" + name)?.value || null;
|
|
1285
|
+
if ((cur.group || null) !== wantedGroup)
|
|
1286
|
+
await api("/api/move", { name: finalName, group: wantedGroup, before: null });
|
|
887
1287
|
toast(finalName + " saved"); open.delete(name); load();
|
|
888
1288
|
} catch (e) { toast(e.message, true); }
|
|
889
1289
|
}
|
|
@@ -899,20 +1299,24 @@ function editGuard() {
|
|
|
899
1299
|
if (open.size || dragging || dragProj) return true;
|
|
900
1300
|
const ae = document.activeElement;
|
|
901
1301
|
return ae && (ae.closest("#services") || ae.closest("#projects") || ae.id === "filter") &&
|
|
902
|
-
["SELECT","INPUT","TEXTAREA"].includes(ae.tagName);
|
|
1302
|
+
(["SELECT","INPUT","TEXTAREA","BUTTON"].includes(ae.tagName) || ae.classList.contains("handle"));
|
|
903
1303
|
}
|
|
904
1304
|
let prevUp = null; // name -> was running (crash detection across polls)
|
|
905
1305
|
function notifyCrash(name, code) {
|
|
906
1306
|
toast(`${name} crashed (exit ${code})`, true);
|
|
907
|
-
if (
|
|
908
|
-
if (Notification.permission === "default") Notification.requestPermission();
|
|
909
|
-
if (Notification.permission === "granted")
|
|
1307
|
+
if (("Notification" in window) && Notification.permission === "granted")
|
|
910
1308
|
new Notification("Stackdeck", { body: `${name} crashed (exit code ${code})` });
|
|
911
1309
|
}
|
|
1310
|
+
let lastSig = null;
|
|
912
1311
|
async function loadServices(force) {
|
|
913
1312
|
if (!force && editGuard()) return;
|
|
914
1313
|
try {
|
|
915
1314
|
const d = await api("/api/services");
|
|
1315
|
+
// Unchanged payload → skip the re-render entirely (keeps focus, open log
|
|
1316
|
+
// panes, and scroll positions intact between polls).
|
|
1317
|
+
const sig = JSON.stringify(d);
|
|
1318
|
+
if (!force && sig === lastSig) return;
|
|
1319
|
+
lastSig = sig;
|
|
916
1320
|
groups = d.groups || []; services = d.services || [];
|
|
917
1321
|
instancesList = d.instances || [];
|
|
918
1322
|
hiddenGroups = d.hiddenGroups || []; hiddenCategories = d.hiddenCategories || [];
|
|
@@ -966,9 +1370,9 @@ async function refreshProjects() {
|
|
|
966
1370
|
const before = projects.length;
|
|
967
1371
|
await loadProjects(true);
|
|
968
1372
|
const diff = projects.length - before;
|
|
969
|
-
toast(diff > 0 ? `rescanned
|
|
970
|
-
: diff < 0 ? `rescanned
|
|
971
|
-
: "rescanned
|
|
1373
|
+
toast(diff > 0 ? `rescanned: ${diff} new project${diff > 1 ? "s" : ""} found`
|
|
1374
|
+
: diff < 0 ? `rescanned: ${-diff} project${diff < -1 ? "s" : ""} gone`
|
|
1375
|
+
: "rescanned: no changes");
|
|
972
1376
|
}
|
|
973
1377
|
|
|
974
1378
|
/* ---------- settings (VLC-style folder tree) ---------- */
|
|
@@ -990,11 +1394,15 @@ function toggleSettings() {
|
|
|
990
1394
|
...(meta.categoryOrder || []).filter(c => existing.includes(c)),
|
|
991
1395
|
...existing.filter(c => !(meta.categoryOrder || []).includes(c)).sort(),
|
|
992
1396
|
];
|
|
993
|
-
document.getElementById("cfg-path").textContent =
|
|
1397
|
+
document.getElementById("cfg-path").textContent = shortP(meta.configPath);
|
|
994
1398
|
document.getElementById("cfg-browser").style.display = "none";
|
|
995
1399
|
renderRoots();
|
|
996
1400
|
renderCatList();
|
|
997
1401
|
}
|
|
1402
|
+
if (show) {
|
|
1403
|
+
const h = document.querySelector("#settings .cfg-head [role=heading]");
|
|
1404
|
+
if (h) { h.setAttribute("tabindex", "-1"); h.focus(); }
|
|
1405
|
+
}
|
|
998
1406
|
}
|
|
999
1407
|
|
|
1000
1408
|
/* category reorder list */
|
|
@@ -1009,9 +1417,19 @@ function renderCatList() {
|
|
|
1009
1417
|
ondragover="event.preventDefault(); if(dragCat!==null && dragCat!=${i}) this.classList.add('drop-before')"
|
|
1010
1418
|
ondragleave="this.classList.remove('drop-before')"
|
|
1011
1419
|
ondrop="dropCat(event, ${i})">
|
|
1012
|
-
<span class="handle">⠿</span> ${esc(c)} <span class="count">${counts[c] || 0}</span>
|
|
1420
|
+
<span class="handle" aria-hidden="true">⠿</span> ${esc(c)} <span class="count">${counts[c] || 0}</span>
|
|
1421
|
+
<button class="ghost" id="catup-${i}" aria-label="move ${esc(c)} up" ${i === 0 ? "disabled" : ""} onclick="moveCat(${i},-1)">↑</button>
|
|
1422
|
+
<button class="ghost" id="catdn-${i}" aria-label="move ${esc(c)} down" ${i === pending.cats.length - 1 ? "disabled" : ""} onclick="moveCat(${i},1)">↓</button>
|
|
1013
1423
|
</div>`).join("") +
|
|
1014
|
-
`<div class="cat-row pinned" title="new and unmapped projects land here">Uncategorized
|
|
1424
|
+
`<div class="cat-row pinned" title="new and unmapped projects land here">Uncategorized · always last <span class="count">${counts["Uncategorized"] || 0}</span></div>`;
|
|
1425
|
+
}
|
|
1426
|
+
function moveCat(i, d) {
|
|
1427
|
+
const j = i + d;
|
|
1428
|
+
if (j < 0 || j >= pending.cats.length) return;
|
|
1429
|
+
[pending.cats[i], pending.cats[j]] = [pending.cats[j], pending.cats[i]];
|
|
1430
|
+
renderCatList();
|
|
1431
|
+
document.getElementById((d < 0 ? "catup-" : "catdn-") + j)?.focus();
|
|
1432
|
+
scheduleCfgSave();
|
|
1015
1433
|
}
|
|
1016
1434
|
function dropCat(ev, to) {
|
|
1017
1435
|
ev.preventDefault();
|
|
@@ -1020,6 +1438,7 @@ function dropCat(ev, to) {
|
|
|
1020
1438
|
pending.cats.splice(to > dragCat ? to - 1 : to, 0, moved);
|
|
1021
1439
|
dragCat = null;
|
|
1022
1440
|
renderCatList();
|
|
1441
|
+
scheduleCfgSave();
|
|
1023
1442
|
}
|
|
1024
1443
|
|
|
1025
1444
|
async function renderRoots() {
|
|
@@ -1042,13 +1461,14 @@ async function renderRoots() {
|
|
|
1042
1461
|
} catch (e) { children = `<div class="child-grid"><span class="cmdline">${esc(e.message)}</span></div>`; }
|
|
1043
1462
|
}
|
|
1044
1463
|
return `<div class="root-row">
|
|
1045
|
-
<
|
|
1464
|
+
<button class="chev-btn" aria-expanded="${isOpen}" aria-label="${isOpen ? "collapse" : "expand"} ${esc(shortP(root))}"
|
|
1465
|
+
onclick="toggleRootOpen(${rArg})"><span class="chev ${isOpen ? "" : "closed"}" aria-hidden="true">▾</span></button>
|
|
1046
1466
|
<b>${esc(shortP(root))}</b>
|
|
1047
1467
|
<button class="ghost" onclick="removeRoot(${rArg})">remove</button>
|
|
1048
1468
|
</div>${children}`;
|
|
1049
1469
|
}));
|
|
1050
1470
|
box.innerHTML = blocks.join("") ||
|
|
1051
|
-
'<div class="cmdline" style="padding:6px 0">no folders yet
|
|
1471
|
+
'<div class="cmdline" style="padding:6px 0">no folders yet: add one below</div>';
|
|
1052
1472
|
}
|
|
1053
1473
|
|
|
1054
1474
|
function toggleRootOpen(root) {
|
|
@@ -1058,10 +1478,12 @@ function toggleRootOpen(root) {
|
|
|
1058
1478
|
function toggleExclude(full, included) {
|
|
1059
1479
|
included ? pending.excludes.delete(full) : pending.excludes.add(full);
|
|
1060
1480
|
renderRoots();
|
|
1481
|
+
scheduleCfgSave();
|
|
1061
1482
|
}
|
|
1062
1483
|
function removeRoot(root) {
|
|
1063
1484
|
pending.roots = pending.roots.filter(r => r !== root);
|
|
1064
1485
|
renderRoots();
|
|
1486
|
+
scheduleCfgSave();
|
|
1065
1487
|
}
|
|
1066
1488
|
|
|
1067
1489
|
/* folder browser for adding a root */
|
|
@@ -1093,22 +1515,50 @@ function pickRoot() {
|
|
|
1093
1515
|
if (browsePath && !pending.roots.includes(browsePath)) {
|
|
1094
1516
|
pending.roots.push(browsePath);
|
|
1095
1517
|
pending.openRoots.add(browsePath);
|
|
1518
|
+
scheduleCfgSave();
|
|
1096
1519
|
}
|
|
1097
1520
|
document.getElementById("cfg-browser").style.display = "none";
|
|
1098
1521
|
renderRoots();
|
|
1099
1522
|
}
|
|
1100
1523
|
|
|
1101
|
-
|
|
1524
|
+
/* settings auto-save: every control applies immediately; the footer shows
|
|
1525
|
+
saving…/saved so the user is never left wondering (no explicit Save). */
|
|
1526
|
+
let cfgSaveT, cfgStatusT;
|
|
1527
|
+
function cfgStatus(msg, sticky) {
|
|
1528
|
+
const el = document.getElementById("cfg-status");
|
|
1529
|
+
if (!el) return;
|
|
1530
|
+
el.textContent = msg;
|
|
1531
|
+
clearTimeout(cfgStatusT);
|
|
1532
|
+
if (msg && !sticky) cfgStatusT = setTimeout(() => (el.textContent = ""), 2000);
|
|
1533
|
+
}
|
|
1534
|
+
function scheduleCfgSave() {
|
|
1535
|
+
cfgStatus("saving…", true);
|
|
1536
|
+
clearTimeout(cfgSaveT);
|
|
1537
|
+
cfgSaveT = setTimeout(async () => {
|
|
1538
|
+
try {
|
|
1539
|
+
await api("/api/config", {
|
|
1540
|
+
projectRoots: pending.roots,
|
|
1541
|
+
excludes: [...pending.excludes],
|
|
1542
|
+
categoryOrder: pending.cats,
|
|
1543
|
+
});
|
|
1544
|
+
cfgStatus("saved ✓");
|
|
1545
|
+
await loadMeta();
|
|
1546
|
+
loadProjects(true);
|
|
1547
|
+
} catch (e) { cfgStatus(""); toast(e.message, true); }
|
|
1548
|
+
}, 500);
|
|
1549
|
+
}
|
|
1550
|
+
async function addCategoryInline() {
|
|
1551
|
+
const el = document.getElementById("new-cat");
|
|
1552
|
+
const name = el.value.trim();
|
|
1553
|
+
if (!name) return;
|
|
1102
1554
|
try {
|
|
1103
|
-
await api("/api/
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
toast("settings saved");
|
|
1109
|
-
document.getElementById("settings").style.display = "none";
|
|
1555
|
+
await api("/api/category", { name });
|
|
1556
|
+
el.value = "";
|
|
1557
|
+
if (!pending.cats.includes(name)) pending.cats.push(name);
|
|
1558
|
+
renderCatList();
|
|
1559
|
+
cfgStatus("saved ✓");
|
|
1110
1560
|
await loadMeta();
|
|
1111
|
-
|
|
1561
|
+
loadProjects(true);
|
|
1112
1562
|
} catch (e) { toast(e.message, true); }
|
|
1113
1563
|
}
|
|
1114
1564
|
async function load() { await Promise.all([loadServices(), loadProjects()]); }
|
|
@@ -1116,6 +1566,11 @@ async function loadMeta() {
|
|
|
1116
1566
|
try {
|
|
1117
1567
|
const m = await api("/api/meta");
|
|
1118
1568
|
meta = m;
|
|
1569
|
+
if (!themeLocked && m.theme !== undefined) {
|
|
1570
|
+
themeKnobs = { ...THEME_KNOB_DEFAULTS, ...(m.themeTokens || {}) };
|
|
1571
|
+
syncEditor();
|
|
1572
|
+
applyTheme(m.theme || "", false);
|
|
1573
|
+
}
|
|
1119
1574
|
if (m.categoryOrder && m.categoryOrder.length) CAT_ORDER = [...m.categoryOrder, "Uncategorized"];
|
|
1120
1575
|
document.getElementById("metaPaths").textContent = `v${m.version}`;
|
|
1121
1576
|
document.getElementById("metaPaths").title = `config: ${shortP(m.configPath)} · logs: ${shortP(m.logDir)}`;
|
|
@@ -1125,6 +1580,60 @@ async function loadMeta() {
|
|
|
1125
1580
|
} catch {}
|
|
1126
1581
|
}
|
|
1127
1582
|
|
|
1583
|
+
/* ---------- keyboard ---------- */
|
|
1584
|
+
|
|
1585
|
+
const REDUCED = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
1586
|
+
// Notification permission must be requested from a user gesture.
|
|
1587
|
+
document.addEventListener("click", () => {
|
|
1588
|
+
if ("Notification" in window && Notification.permission === "default") Notification.requestPermission();
|
|
1589
|
+
}, { once: true });
|
|
1590
|
+
|
|
1591
|
+
async function moveSvcKey(name, dir) {
|
|
1592
|
+
const s = services.find(x => x.name === name);
|
|
1593
|
+
if (!s) return;
|
|
1594
|
+
const g = s.group || null;
|
|
1595
|
+
const rows = services.filter(x => (x.group || null) === g && (showHidden || !x.hidden));
|
|
1596
|
+
const i = rows.findIndex(x => x.name === name);
|
|
1597
|
+
const sections = groups.length ? [...groups, null] : [null];
|
|
1598
|
+
const gi = sections.findIndex(x => (x || null) === g);
|
|
1599
|
+
if (dir < 0) {
|
|
1600
|
+
if (i > 0) return moveKeep(name, g, rows[i - 1].name);
|
|
1601
|
+
if (gi > 0) return moveKeep(name, sections[gi - 1], null); // end of previous section
|
|
1602
|
+
} else {
|
|
1603
|
+
if (i < rows.length - 1) return moveKeep(name, g, rows[i + 2]?.name ?? null);
|
|
1604
|
+
if (gi >= 0 && gi < sections.length - 1) { // top of next section
|
|
1605
|
+
const next = sections[gi + 1];
|
|
1606
|
+
const nrows = services.filter(x => (x.group || null) === (next || null) && (showHidden || !x.hidden));
|
|
1607
|
+
return moveKeep(name, next, nrows[0]?.name ?? null);
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
async function moveKeep(name, group, before) {
|
|
1612
|
+
try { await api("/api/move", { name, group, before }); }
|
|
1613
|
+
catch (e) { toast(e.message, true); }
|
|
1614
|
+
await loadServices(true);
|
|
1615
|
+
document.getElementById("h-" + name)?.focus();
|
|
1616
|
+
}
|
|
1617
|
+
document.addEventListener("keydown", (ev) => {
|
|
1618
|
+
const tag = document.activeElement?.tagName;
|
|
1619
|
+
if (ev.key === "Escape") {
|
|
1620
|
+
const settings = document.getElementById("settings");
|
|
1621
|
+
if (settings.style.display !== "none") { settings.style.display = "none"; return; }
|
|
1622
|
+
if (open.size) { open.clear(); renderServices(); }
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
if (ev.key === "/" && !["INPUT","TEXTAREA","SELECT"].includes(tag)) {
|
|
1626
|
+
ev.preventDefault();
|
|
1627
|
+
document.getElementById("filter").focus();
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
if ((ev.key === "ArrowUp" || ev.key === "ArrowDown") &&
|
|
1631
|
+
ev.target.classList?.contains("handle") && ev.target.id.startsWith("h-")) {
|
|
1632
|
+
ev.preventDefault();
|
|
1633
|
+
moveSvcKey(ev.target.id.slice(2), ev.key === "ArrowUp" ? -1 : 1);
|
|
1634
|
+
}
|
|
1635
|
+
});
|
|
1636
|
+
|
|
1128
1637
|
load(); loadMeta(); loadInfra();
|
|
1129
1638
|
setInterval(() => { if (document.visibilityState === "visible") loadInfra(); }, 15000);
|
|
1130
1639
|
setInterval(() => { if (document.visibilityState === "visible") loadServices(); }, 5000);
|