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.
Files changed (4) hide show
  1. package/README.md +18 -7
  2. package/index.html +643 -134
  3. package/package.json +30 -7
  4. 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.66 0.014 80);
17
- --faint: oklch(0.47 0.012 80);
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:1000px; margin:0 auto;
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:12.5px; }
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:1000px; margin:0 auto; padding:10px 28px 96px; }
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:11.5px; padding:3px 9px; }
53
+ .section-label button { font-size:12px; padding:3px 9px; }
50
54
  .section-label input {
51
- margin-left:auto; width:220px; font-size:12.5px; letter-spacing:0; text-transform:none; font-weight:400;
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:10px; overflow:hidden; }
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 .15s ease-out; }
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:13.5px; min-width:96px; }
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:11.5px; color:var(--faint);
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:10.5px; font-weight:600; letter-spacing:.03em; padding:2px 7px;
77
- border-radius:5px; white-space:nowrap;
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:var(--accent-dim); }
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(--faint); background:oklch(0.30 0.012 75 / .35); }
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:11.5px; padding:3px 8px; }
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:7px;
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
- .branch-slot select.dirty { border-color:var(--accent); }
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 .15s ease-out;
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 there */
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 12.5px var(--sans); color:var(--text);
126
- background:var(--surface-2); border:1px solid var(--border); border-radius:7px;
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 .15s ease-out, background .15s ease-out, color .15s ease-out;
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.97 0.01 26); }
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
- .drawer.open { display:block; }
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:11.5px; text-align:right; }
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:8px;
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:11.5px; line-height:1.55; white-space:pre-wrap; color:var(--dim);
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:var(--faint); font-style:italic; }
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:12.5px; }
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:11.5px; font-weight:600; color:var(--dim); letter-spacing:.04em;
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
- .group-head .title { display:flex; align-items:center; gap:8px; cursor:pointer; user-select:none; }
178
- .group-head .title:hover { color:var(--text); }
179
- .chev { display:inline-block; transition:transform .15s ease-out; font-size:10px; color:var(--faint); }
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:12.5px;
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:8px; padding:10px 12px; margin:8px 0;
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 12.5px var(--mono); padding:3px 8px;
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:7px; background:var(--bg);
215
- font-size:12.5px; cursor:grab; user-select:none;
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:11.5px; }
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:9px;
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-out, transform .2s ease-out;
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:oklch(0.85 0.06 26); }
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"></span>
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="drawer open" style="padding:16px 18px">
244
- <div class="cfg-label">Scanned folders <span class="count">tick a subfolder to include it, untick to exclude</span></div>
245
- <div id="cfg-roots-list"></div>
246
- <div id="cfg-browser" style="display:none"></div>
247
- <button class="ghost" onclick="browseFolders()">+ add folder…</button>
248
- <div class="cfg-label" style="margin-top:16px">Project categories <span class="count">drag to change the order they appear on the board</span></div>
249
- <div id="cfg-cats" class="cat-list"></div>
250
- <div style="display:flex; gap:10px; align-items:center; margin-top:12px">
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="renderProjects()">
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 `<span class="title" onclick="toggleCollapse(decodeURIComponent('${encodeURIComponent(key)}'))">
309
- <span class="chev ${collapsed.has(key) ? "closed" : ""}">▾</span>${label}
310
- <span class="count">${count}</span></span>`;
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} Kill first to switch the main checkout, or ⧉ to run the selected branch in a parallel worktree${s.dirty ? " · uncommitted changes" : ""}`
381
- : (s.dirty ? "uncommitted changes commit or stash to switch branch" : "branch to run (⧉ runs it in a parallel worktree)");
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" title="run the selected branch in a parallel worktree (own checkout, auto $PORT)"
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" draggable="true" title="drag to reorder or move to a section"
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 stop it with its own manager, e.g. brew services stop">auto-restarted</span>'
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" onclick="toggleLogs('${s.name}')">logs</button>
416
- <button class="ghost" onclick="toggleEdit('${s.name}')">edit</button>
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"} .env in dir loads automatically; these override it'>${esc(JSON.stringify(s.env || {}))}</textarea>
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>depends on</label><input id="dep-${s.name}" value="${esc((s.dependsOn || []).join(", "))}" placeholder="comma-separated service names, started first">
434
- <label>ready when</label><input id="rw-${s.name}" value="${esc((s.readyWhen && s.readyWhen.log) || "")}" placeholder="regex matched against its logs (default: port opens)">
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…" oninput="filterLogs('${s.name}')">
444
- <div class="logs" id="log-${s.name}"></div>
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(", ")).slice(0, 80)}</span>`
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 dirty" title="parallel worktree instance">worktree</span>
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" onclick="toggleLogs('${esc(i.key)}')">logs</button>
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…" oninput="filterLogs('${esc(i.key)}')">
737
- <div class="logs" id="log-${i.key}"></div>
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:"#57534c",31:"#e06c5f",32:"#84c17b",33:"#d9a05b",34:"#7aa2d4",35:"#c092d0",36:"#72bcb8",37:"#b8b2a7",
803
- 90:"#6e6a63",91:"#f08d80",92:"#a0d598",93:"#e8bc7e",94:"#9dbfe6",95:"#d5b0e2",96:"#93d5d1",97:"#d6d0c4" };
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
- streams[name]?.close();
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
- if (!logMatches(name, line)) return;
848
- pane.insertAdjacentHTML("beforeend", ansiToHtml(line) + "\n");
849
- while (pane.childNodes.length > 6000) pane.removeChild(pane.firstChild);
850
- pane.scrollTop = pane.scrollHeight;
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 (!("Notification" in window)) return;
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 ${diff} new project${diff > 1 ? "s" : ""} found`
970
- : diff < 0 ? `rescanned ${-diff} project${diff < -1 ? "s" : ""} gone`
971
- : "rescanned no changes");
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 = "saved to " + shortP(meta.configPath);
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 always last <span class="count">${counts["Uncategorized"] || 0}</span></div>`;
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
- <span class="chev ${isOpen ? "" : "closed"}" onclick="toggleRootOpen(${rArg})">▾</span>
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 add one below</div>';
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
- async function saveConfig() {
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/config", {
1104
- projectRoots: pending.roots,
1105
- excludes: [...pending.excludes],
1106
- categoryOrder: pending.cats,
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
- await loadProjects(true);
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);