@lchase/kiln 0.15.0 → 0.17.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/assets/serve.js CHANGED
@@ -1,5 +1,6 @@
1
1
  (function () {
2
2
  var KEY = "kiln-needs-you";
3
+ var NOTIFY_KEY = "kiln-notify";
3
4
  var body = document.body;
4
5
  var board = document.getElementById("board");
5
6
  var count = Number(body.getAttribute("data-needs-you") || "0");
@@ -12,7 +13,7 @@
12
13
  if (raw === null) return;
13
14
  var prev = Number(raw);
14
15
  if (!isFinite(prev) || n <= prev) return;
15
- if (typeof Notification === "undefined" || Notification.permission !== "granted") return;
16
+ if (notifyMode() !== "on") return;
16
17
  var added = n - prev;
17
18
  try {
18
19
  new Notification("Kiln", { body: added === 1 ? "1 job needs you" : added + " jobs need you" });
@@ -59,7 +60,7 @@
59
60
  if (slimCount) slimCount.textContent = String(visible);
60
61
  var empty = col.querySelector(".empty");
61
62
  if (empty) empty.hidden = visible !== 0;
62
- tracks.push(slim ? "40px" : "minmax(0, 1fr)");
63
+ tracks.push(slim ? "var(--col-slim)" : "minmax(var(--col-min), 1fr)");
63
64
  }
64
65
  board.style.setProperty("--cols", tracks.join(" "));
65
66
  }
@@ -257,26 +258,65 @@
257
258
  try { localStorage.removeItem("kiln-drawer"); } catch (err) { /* private mode */ }
258
259
  }
259
260
 
260
- function notifyLabel() {
261
+ var notifyAsking = false;
262
+
263
+ function notifyPref() {
264
+ try { return localStorage.getItem(NOTIFY_KEY) === "off" ? "off" : "on"; }
265
+ catch (err) { return "on"; }
266
+ }
267
+
268
+ function setNotifyPref(value) {
269
+ try { localStorage.setItem(NOTIFY_KEY, value); } catch (err) { /* private mode */ }
270
+ }
271
+
272
+ function notifyMode() {
273
+ if (typeof Notification === "undefined") return "unavailable";
274
+ if (Notification.permission === "denied") return "blocked";
275
+ if (Notification.permission === "granted") return notifyPref() === "off" ? "off" : "on";
276
+ return "ask";
277
+ }
278
+
279
+ function notifyCopy(mode) {
280
+ if (mode === "unavailable") return { label: "Notifications unavailable", note: "" };
281
+ if (mode === "blocked") return { label: "Notifications blocked", note: "Allow notifications for this site in the browser." };
282
+ if (mode === "on") return { label: "Notifications on", note: "" };
283
+ return { label: "Notify me", note: "" };
284
+ }
285
+
286
+ function renderNotify() {
261
287
  var button = document.getElementById("notify");
262
288
  if (!button) return;
263
- if (typeof Notification === "undefined") {
264
- button.hidden = true;
265
- return;
266
- }
289
+ var mode = notifyMode();
290
+ var copy = notifyCopy(mode);
267
291
  button.hidden = false;
268
- if (Notification.permission === "granted") {
269
- button.setAttribute("data-tip", "Notifications on");
270
- button.setAttribute("aria-label", "Notifications on");
271
- } else if (Notification.permission === "denied") {
272
- button.setAttribute("data-tip", "Notifications blocked");
273
- button.setAttribute("aria-label", "Notifications blocked");
274
- } else {
275
- button.setAttribute("data-tip", "Notify me");
276
- button.setAttribute("aria-label", "Notify me");
292
+ button.disabled = mode === "unavailable";
293
+ button.setAttribute("data-state", mode);
294
+ button.setAttribute("aria-pressed", mode === "on" ? "true" : "false");
295
+ button.setAttribute("aria-label", copy.label);
296
+ if (copy.note) button.setAttribute("aria-describedby", "notify-note");
297
+ else button.removeAttribute("aria-describedby");
298
+ var label = document.getElementById("notify-label");
299
+ if (label) label.textContent = copy.label;
300
+ var note = document.getElementById("notify-note");
301
+ if (note) {
302
+ note.hidden = !copy.note;
303
+ note.textContent = copy.note;
277
304
  }
278
305
  }
279
306
 
307
+ function askNotify() {
308
+ if (notifyAsking || typeof Notification === "undefined" || !Notification.requestPermission) return;
309
+ notifyAsking = true;
310
+ var pending = Notification.requestPermission();
311
+ var done = function () {
312
+ notifyAsking = false;
313
+ if (typeof Notification !== "undefined" && Notification.permission === "granted") setNotifyPref("on");
314
+ renderNotify();
315
+ };
316
+ if (pending && typeof pending.then === "function") pending.then(done, done);
317
+ else done();
318
+ }
319
+
280
320
  var titleDirty = false;
281
321
  var issueDirty = false;
282
322
  var sourceKind = "prompt";
@@ -284,16 +324,204 @@
284
324
  var ticketsDir = "";
285
325
  var fsPath = "";
286
326
  var sending = false;
327
+ var intakeTouched = {};
328
+ var intakeSubmitTried = false;
287
329
  var FS_KEY = "kiln-fs-dir";
288
330
 
331
+ function pickLimit() {
332
+ var n = Number(body.getAttribute("data-pick-limit") || "5");
333
+ return n > 0 ? n : 5;
334
+ }
335
+
289
336
  function selectedRepo() {
290
- var pressed = document.querySelector("#repo-pick .pick[aria-pressed='true']");
291
- return pressed ? pressed.getAttribute("data-repo") || "" : "";
337
+ var box = document.getElementById("repo-pick");
338
+ return box ? box.getAttribute("data-value") || "" : "";
292
339
  }
293
340
 
294
341
  function intakeRepoPath() {
295
- var pressed = document.querySelector("#repo-pick .pick[aria-pressed='true']");
296
- return pressed ? pressed.getAttribute("data-path") || "" : "";
342
+ var box = document.getElementById("repo-pick");
343
+ return box ? box.getAttribute("data-path") || "" : "";
344
+ }
345
+
346
+ function selectedWorkflow() {
347
+ var select = document.getElementById("workflow");
348
+ if (select) return select.value || "";
349
+ var host = document.getElementById("workflow-pick");
350
+ return host ? host.getAttribute("data-workflow") || "" : "";
351
+ }
352
+
353
+ function setFieldError(id, message, reveal) {
354
+ var el = document.getElementById(id);
355
+ if (!el) return;
356
+ var on = Boolean(message) && reveal;
357
+ el.hidden = !on;
358
+ el.textContent = on ? message : "";
359
+ var field = el.closest(".field");
360
+ if (!field) return;
361
+ var control = field.querySelector("textarea, input, select, [role='combobox'], [role='group']");
362
+ if (!control) return;
363
+ if (on) control.setAttribute("aria-invalid", "true");
364
+ else control.removeAttribute("aria-invalid");
365
+ }
366
+
367
+ function showFieldError(name) {
368
+ return intakeSubmitTried || intakeTouched[name] === true;
369
+ }
370
+
371
+ function syncValidity() {
372
+ var repo = selectedRepo();
373
+ var workflow = selectedWorkflow();
374
+ var source = sourceBody();
375
+ var prompt = source.kind === "prompt" && !String(source.text || "").trim() ? "Prompt is required." : "";
376
+ var spec = source.kind === "spec" && !source.path ? "Choose a spec file." : "";
377
+ var tickets = source.kind === "tickets" && !source.dir ? "Choose a tickets folder." : "";
378
+ var jira = source.kind === "jira" && !source.key ? "Enter a Jira key." : "";
379
+ setFieldError("repo-error", repo ? "" : "Choose a repo.", showFieldError("repo"));
380
+ setFieldError("workflow-error", workflow ? "" : "Choose a workflow.", showFieldError("workflow"));
381
+ setFieldError("prompt-error", prompt, showFieldError("prompt"));
382
+ setFieldError("source-error", spec || tickets, showFieldError("source"));
383
+ setFieldError("jira-error", jira, showFieldError("jira"));
384
+ var blocked = !repo || !workflow || Boolean(prompt || spec || tickets || jira);
385
+ var submit = document.getElementById("add-submit");
386
+ if (submit) submit.disabled = blocked || sending;
387
+ var preview = document.getElementById("preview-job");
388
+ if (preview) preview.disabled = blocked || sending;
389
+ return !blocked;
390
+ }
391
+
392
+ function repoListKey(repos) {
393
+ var parts = [];
394
+ for (var i = 0; i < repos.length; i++) {
395
+ var repo = repos[i];
396
+ if (!repo || !repo.name) continue;
397
+ parts.push(repo.name + "\t" + (repo.path || ""));
398
+ }
399
+ return parts.join("\n");
400
+ }
401
+
402
+ function paintRepos(repos) {
403
+ var box = document.getElementById("repo-pick");
404
+ if (!box) return;
405
+ var items = [];
406
+ for (var i = 0; i < repos.length; i++) if (repos[i] && repos[i].name) items.push(repos[i]);
407
+ var key = repoListKey(items);
408
+ if (box.getAttribute("data-list") === key) return;
409
+ var current = box.getAttribute("data-value") || "";
410
+ var selected = null;
411
+ for (var s = 0; s < items.length; s++) if (items[s].name === current) selected = items[s];
412
+ if (!selected && items.length) selected = items[0];
413
+ var value = selected ? selected.name : "";
414
+ var path = selected ? (selected.path || "") : "";
415
+ var mode = !items.length ? "empty" : items.length > pickLimit() ? "select" : "chips";
416
+ var html = "";
417
+ if (mode === "empty") html = '<p class="empty">No repos are registered.</p>';
418
+ if (mode === "chips") {
419
+ for (var c = 0; c < items.length; c++) {
420
+ var chip = items[c];
421
+ var pressed = chip.name === value;
422
+ html += '<button type="button" class="pick" data-repo="' + esc(chip.name) + '" data-path="' + esc(chip.path || "") + '" aria-pressed="' + (pressed ? "true" : "false") + '">' + esc(chip.name) + "</button>";
423
+ }
424
+ }
425
+ if (mode === "select") {
426
+ html = '<input id="repo-pick-query" class="combo-input" type="text" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="repo-pick-list" aria-labelledby="repo-label" placeholder="Search repos" value="' + esc(value) + '" autocomplete="off" spellcheck="false">';
427
+ html += '<ul class="combo-list" id="repo-pick-list" role="listbox" hidden>';
428
+ for (var o = 0; o < items.length; o++) {
429
+ var opt = items[o];
430
+ html += '<li role="option" id="repo-opt-' + o + '" data-value="' + esc(opt.name) + '" data-path="' + esc(opt.path || "") + '" aria-selected="' + (opt.name === value ? "true" : "false") + '">' + esc(opt.name) + "</li>";
431
+ }
432
+ html += '<li class="empty" role="presentation" hidden>No matching repos.</li></ul>';
433
+ }
434
+ box.className = mode === "select" ? "combo" : "picks";
435
+ box.setAttribute("data-mode", mode);
436
+ box.setAttribute("data-value", value);
437
+ box.setAttribute("data-path", path);
438
+ box.setAttribute("data-list", key);
439
+ if (mode === "select") {
440
+ box.removeAttribute("role");
441
+ box.removeAttribute("aria-labelledby");
442
+ } else {
443
+ box.setAttribute("role", "group");
444
+ box.setAttribute("aria-labelledby", "repo-label");
445
+ }
446
+ box.innerHTML = html;
447
+ syncValidity();
448
+ }
449
+
450
+ function visibleOptions(box) {
451
+ var all = box.querySelectorAll("[role='option']");
452
+ var out = [];
453
+ for (var i = 0; i < all.length; i++) if (!all[i].hidden) out.push(all[i]);
454
+ return out;
455
+ }
456
+
457
+ function applyComboFilter(box, query) {
458
+ var q = String(query || "").trim().toLowerCase();
459
+ var options = box.querySelectorAll("[role='option']");
460
+ var shown = 0;
461
+ for (var i = 0; i < options.length; i++) {
462
+ var name = (options[i].getAttribute("data-value") || options[i].textContent || "").toLowerCase();
463
+ var hide = q.length > 0 && name.indexOf(q) === -1;
464
+ options[i].hidden = hide;
465
+ if (!hide) shown += 1;
466
+ }
467
+ var empty = box.querySelector(".combo-list .empty");
468
+ if (empty) empty.hidden = shown !== 0;
469
+ setComboActive(box, 0);
470
+ }
471
+
472
+ function setComboActive(box, index) {
473
+ var options = visibleOptions(box);
474
+ var input = box.querySelector("[role='combobox']");
475
+ if (!options.length) {
476
+ box.removeAttribute("data-active");
477
+ if (input) input.removeAttribute("aria-activedescendant");
478
+ return;
479
+ }
480
+ if (index < 0) index = options.length - 1;
481
+ if (index >= options.length) index = 0;
482
+ box.setAttribute("data-active", String(index));
483
+ for (var i = 0; i < options.length; i++) options[i].classList.toggle("active", i === index);
484
+ if (input && options[index].id) input.setAttribute("aria-activedescendant", options[index].id);
485
+ }
486
+
487
+ function openCombo(box, filter) {
488
+ if (!box) return;
489
+ var list = box.querySelector("[role='listbox']");
490
+ var input = box.querySelector("[role='combobox']");
491
+ if (!list || !input) return;
492
+ list.hidden = false;
493
+ input.setAttribute("aria-expanded", "true");
494
+ applyComboFilter(box, filter ? input.value : "");
495
+ }
496
+
497
+ function closeCombo(box) {
498
+ var list = box.querySelector("[role='listbox']");
499
+ var input = box.querySelector("[role='combobox']");
500
+ if (list) list.hidden = true;
501
+ if (input) {
502
+ input.setAttribute("aria-expanded", "false");
503
+ input.removeAttribute("aria-activedescendant");
504
+ input.value = box.getAttribute("data-value") || "";
505
+ }
506
+ box.removeAttribute("data-active");
507
+ }
508
+
509
+ function closeCombos(except) {
510
+ var boxes = document.querySelectorAll(".combo");
511
+ for (var i = 0; i < boxes.length; i++) if (boxes[i] !== except) closeCombo(boxes[i]);
512
+ }
513
+
514
+ function chooseComboOption(box, option) {
515
+ if (!box || !option) return;
516
+ var value = option.getAttribute("data-value") || "";
517
+ box.setAttribute("data-value", value);
518
+ if (option.hasAttribute("data-path")) box.setAttribute("data-path", option.getAttribute("data-path") || "");
519
+ var options = box.querySelectorAll("[role='option']");
520
+ for (var i = 0; i < options.length; i++) {
521
+ options[i].setAttribute("aria-selected", options[i] === option ? "true" : "false");
522
+ }
523
+ closeCombo(box);
524
+ syncValidity();
297
525
  }
298
526
 
299
527
  function showIntakeError(message) {
@@ -346,6 +574,7 @@
346
574
  if (sourceKind === "tickets") html += '<button type="button" class="fs-use" data-path="' + esc(fsPath) + '">Use this folder</button>';
347
575
  host.innerHTML = html;
348
576
  if (sourceKind === "tickets") setPicked(fsPath);
577
+ syncValidity();
349
578
  }
350
579
 
351
580
  function chooseSpec(data) {
@@ -356,6 +585,7 @@
356
585
  var input = document.getElementById("job-title");
357
586
  if (input && heading) input.value = heading;
358
587
  }
588
+ syncValidity();
359
589
  }
360
590
 
361
591
  function loadFs(path, fallback) {
@@ -417,6 +647,7 @@
417
647
  }
418
648
  syncIssueDefault();
419
649
  if (sourceKind === "spec" || sourceKind === "tickets") openBrowser();
650
+ syncValidity();
420
651
  }
421
652
 
422
653
  function syncIssueDefault() {
@@ -451,15 +682,12 @@
451
682
 
452
683
  function submitIntake(dryRun) {
453
684
  if (sending) return;
685
+ intakeSubmitTried = true;
686
+ if (!syncValidity()) return;
454
687
  var repo = selectedRepo();
455
- var workflow = document.getElementById("workflow");
688
+ var workflow = selectedWorkflow();
456
689
  var source = sourceBody();
457
- if (!repo) { showIntakeError("Name a repo."); return; }
458
- if (source.kind === "prompt" && !String(source.text || "").trim()) { showIntakeError("Prompt is empty."); return; }
459
- if (source.kind === "spec" && !source.path) { showIntakeError("Choose a spec file."); return; }
460
- if (source.kind === "tickets" && !source.dir) { showIntakeError("Choose a tickets folder."); return; }
461
- if (source.kind === "jira" && !source.key) { showIntakeError("Enter a Jira key."); return; }
462
- var payload = { repo: repo, workflow: workflow ? workflow.value : "", source: source };
690
+ var payload = { repo: repo, workflow: workflow, source: source };
463
691
  if (dryRun) payload.dryRun = true;
464
692
  if (sourceKind !== "tickets") {
465
693
  var issue = document.getElementById("job-issue");
@@ -473,6 +701,7 @@
473
701
  }
474
702
  }
475
703
  sending = true;
704
+ syncValidity();
476
705
  showIntakeError("");
477
706
  fetch("/api/jobs", {
478
707
  method: "POST",
@@ -488,6 +717,7 @@
488
717
  })
489
718
  .then(function (result) {
490
719
  sending = false;
720
+ syncValidity();
491
721
  if (!result.ok) {
492
722
  showIntakeError((result.data && result.data.error) || "Could not add the job.");
493
723
  return;
@@ -507,6 +737,7 @@
507
737
  })
508
738
  .catch(function () {
509
739
  sending = false;
740
+ syncValidity();
510
741
  showIntakeError("Could not add the job.");
511
742
  });
512
743
  }
@@ -523,28 +754,7 @@
523
754
  }
524
755
  }
525
756
  if (!data || !Array.isArray(data.repos)) return;
526
- var box = document.getElementById("repo-pick");
527
- if (!box) return;
528
- var current = selectedRepo();
529
- var still = false;
530
- var html = "";
531
- if (!data.repos.length) html = '<p class="empty">No repos are registered.</p>';
532
- for (var i = 0; i < data.repos.length; i++) {
533
- var repo = data.repos[i];
534
- if (!repo || !repo.name) continue;
535
- if (repo.name === current) still = true;
536
- }
537
- if (data.repos.length) {
538
- html = "";
539
- for (var j = 0; j < data.repos.length; j++) {
540
- var item = data.repos[j];
541
- var pressed = still ? item.name === current : j === 0;
542
- html += '<button type="button" class="pick" data-repo="' + esc(item.name) + '" data-path="' + esc(item.path || "") + '" aria-pressed="' + (pressed ? "true" : "false") + '">' + esc(item.name) + "</button>";
543
- }
544
- }
545
- if (box.getAttribute("data-repos") === html) return;
546
- box.setAttribute("data-repos", html);
547
- box.innerHTML = html;
757
+ paintRepos(data.repos);
548
758
  }
549
759
 
550
760
  function showFresh() {
@@ -598,17 +808,31 @@
598
808
  if (message && slot.scrollIntoView) slot.scrollIntoView({ block: "nearest" });
599
809
  }
600
810
 
811
+ var settingsEpoch = 0;
812
+ var themeApplied = document.documentElement.getAttribute("data-theme") || "kiln";
813
+ var themeQueued = null;
814
+ var themeSaving = false;
815
+
601
816
  function setSettingsTab(tab) {
602
817
  var repos = document.getElementById("settings-repos");
603
818
  var globalPane = document.getElementById("settings-global");
819
+ var appearance = document.getElementById("settings-appearance");
604
820
  if (repos) repos.hidden = tab !== "repos";
605
821
  if (globalPane) globalPane.hidden = tab !== "global";
822
+ if (appearance) appearance.hidden = tab !== "appearance";
606
823
  var buttons = document.querySelectorAll("#settings-form [data-settings-tab]");
607
824
  for (var i = 0; i < buttons.length; i++) {
608
825
  buttons[i].setAttribute("aria-pressed", buttons[i].getAttribute("data-settings-tab") === tab ? "true" : "false");
609
826
  }
610
827
  }
611
828
 
829
+ function markTheme(id) {
830
+ var buttons = document.querySelectorAll("#theme-pick [data-theme-choice]");
831
+ for (var i = 0; i < buttons.length; i++) {
832
+ buttons[i].setAttribute("aria-pressed", buttons[i].getAttribute("data-theme-choice") === id ? "true" : "false");
833
+ }
834
+ }
835
+
612
836
  function setPressed(selector, attr, value) {
613
837
  var buttons = document.querySelectorAll(selector);
614
838
  for (var i = 0; i < buttons.length; i++) {
@@ -868,9 +1092,87 @@
868
1092
  if (mode) sandbox.mode = mode;
869
1093
  if (domains.length) sandbox.allowDomains = domains;
870
1094
  if (mode || domains.length) body.sandbox = sandbox;
1095
+ var themeButton = document.querySelector('#theme-pick [aria-pressed="true"]');
1096
+ var themeId = themeButton ? themeButton.getAttribute("data-theme-choice") : "";
1097
+ if (themeId) body.board = { theme: themeId };
1098
+ return body;
1099
+ }
1100
+
1101
+ function preservedSettings(data) {
1102
+ var body = { revision: data.revision, repos: data.repos || {} };
1103
+ ["stallTimeoutMs", "maxAttempts", "maxFixRounds", "maxSameFingerprint", "dailyDollarCap"].forEach(function (key) {
1104
+ if (data[key] !== undefined && data[key] !== null) body[key] = data[key];
1105
+ });
1106
+ if (data.sandbox) body.sandbox = data.sandbox;
871
1107
  return body;
872
1108
  }
873
1109
 
1110
+ function chooseTheme(id) {
1111
+ if (!id) return;
1112
+ settingsEpoch += 1;
1113
+ document.documentElement.setAttribute("data-theme", id);
1114
+ markTheme(id);
1115
+ themeQueued = id;
1116
+ if (!themeSaving) pumpTheme();
1117
+ }
1118
+
1119
+ function pumpTheme() {
1120
+ if (!themeQueued) return;
1121
+ var id = themeQueued;
1122
+ themeQueued = null;
1123
+ themeSaving = true;
1124
+ fetch("/api/settings", { cache: "no-store", headers: { Accept: "application/json" } })
1125
+ .then(function (response) { return response.json().then(function (data) { return { ok: response.ok, data: data }; }); })
1126
+ .then(function (result) {
1127
+ if (!result.ok) throw new Error((result.data && result.data.error) || "Could not load settings.");
1128
+ var body = preservedSettings(result.data);
1129
+ body.board = { theme: id };
1130
+ return fetch("/api/settings", {
1131
+ method: "PUT",
1132
+ headers: {
1133
+ Accept: "application/json",
1134
+ "Content-Type": "application/json",
1135
+ "X-Kiln-CSRF": bodyCsrf(),
1136
+ },
1137
+ body: JSON.stringify(body),
1138
+ });
1139
+ })
1140
+ .then(function (response) { return response.json().then(function (data) { return { ok: response.ok, data: data }; }); })
1141
+ .then(function (result) {
1142
+ themeSaving = false;
1143
+ if (!result.ok) {
1144
+ if (!themeQueued) restoreTheme();
1145
+ showSettingsError((result.data && result.data.error) || "Could not save the theme.");
1146
+ var fields = result.data && result.data.fields;
1147
+ if (fields && fields["board.theme"]) showSlot("board.theme", fields["board.theme"]);
1148
+ if (themeQueued) pumpTheme();
1149
+ return;
1150
+ }
1151
+ themeApplied = (result.data.board && result.data.board.theme) || id;
1152
+ if (!settingsDoc) applySettingsDocument(result.data);
1153
+ else if (result.data.revision) {
1154
+ settingsDoc.revision = result.data.revision;
1155
+ settingsDoc.board = result.data.board;
1156
+ }
1157
+ if (themeQueued) pumpTheme();
1158
+ })
1159
+ .catch(function (err) {
1160
+ themeSaving = false;
1161
+ if (!themeQueued) restoreTheme();
1162
+ showSettingsError(err && err.message ? err.message : "Could not save the theme.");
1163
+ if (themeQueued) pumpTheme();
1164
+ });
1165
+ }
1166
+
1167
+ function restoreTheme() {
1168
+ document.documentElement.setAttribute("data-theme", themeApplied);
1169
+ markTheme(themeApplied);
1170
+ }
1171
+
1172
+ function bodyCsrf() {
1173
+ return body.getAttribute("data-csrf") || "";
1174
+ }
1175
+
874
1176
  function applyFieldErrors(fields) {
875
1177
  var keys = Object.keys(fields || {});
876
1178
  var lines = [];
@@ -896,6 +1198,11 @@
896
1198
  showSlot(key, message);
897
1199
  continue;
898
1200
  }
1201
+ if (key === "board.theme") {
1202
+ setSettingsTab("appearance");
1203
+ showSlot("board.theme", message);
1204
+ continue;
1205
+ }
899
1206
  if (key === "name" || key.indexOf("repos.") === 0) {
900
1207
  setSettingsTab("repos");
901
1208
  showSlot("name", message);
@@ -910,6 +1217,12 @@
910
1217
  var pathEl = document.getElementById("settings-path");
911
1218
  if (pathEl) pathEl.textContent = data.path || "";
912
1219
  fillGlobal(data);
1220
+ var themeId = data.board && data.board.theme ? data.board.theme : "kiln";
1221
+ if (!themeSaving && !themeQueued) {
1222
+ themeApplied = themeId;
1223
+ document.documentElement.setAttribute("data-theme", themeId);
1224
+ markTheme(themeId);
1225
+ }
913
1226
  if (editing && editing.original && Object.prototype.hasOwnProperty.call(repoDraft, editing.original)) {
914
1227
  fillEditor(editing.original, repoDraft[editing.original]);
915
1228
  } else {
@@ -922,11 +1235,13 @@
922
1235
  clearSettingsErrors();
923
1236
  var saved = document.getElementById("settings-saved");
924
1237
  if (saved) saved.hidden = true;
1238
+ var epoch = settingsEpoch;
925
1239
  fetch("/api/settings", { cache: "no-store", headers: { Accept: "application/json" } })
926
1240
  .then(function (response) {
927
1241
  return response.json().then(function (data) { return { ok: response.ok, status: response.status, data: data }; });
928
1242
  })
929
1243
  .then(function (result) {
1244
+ if (epoch !== settingsEpoch) return;
930
1245
  if (!result.ok) {
931
1246
  showSettingsError((result.data && result.data.error) || "Could not load settings.");
932
1247
  return;
@@ -959,11 +1274,8 @@
959
1274
  if (buttonId === id) on = enabled;
960
1275
  toggles.push({ id: buttonId, enabled: on });
961
1276
  }
962
- var body = { revision: data.revision, repos: data.repos || {}, triggers: toggles };
963
- ["stallTimeoutMs", "maxAttempts", "maxFixRounds", "maxSameFingerprint", "dailyDollarCap"].forEach(function (key) {
964
- if (data[key] !== undefined && data[key] !== null) body[key] = data[key];
965
- });
966
- if (data.sandbox) body.sandbox = data.sandbox;
1277
+ var body = preservedSettings(data);
1278
+ body.triggers = toggles;
967
1279
  return fetch("/api/settings", {
968
1280
  method: "PUT",
969
1281
  headers: {
@@ -999,7 +1311,7 @@
999
1311
  headers: {
1000
1312
  Accept: "application/json",
1001
1313
  "Content-Type": "application/json",
1002
- "X-Kiln-CSRF": body.getAttribute("data-csrf") || "",
1314
+ "X-Kiln-CSRF": bodyCsrf(),
1003
1315
  },
1004
1316
  body: JSON.stringify(payload),
1005
1317
  })
@@ -1088,6 +1400,17 @@
1088
1400
  document.addEventListener("click", function (event) {
1089
1401
  var target = event.target;
1090
1402
  if (!target || !target.closest) return;
1403
+ var option = target.closest(".combo [role='option']");
1404
+ if (option) {
1405
+ chooseComboOption(option.closest(".combo"), option);
1406
+ return;
1407
+ }
1408
+ var query = target.closest(".combo [role='combobox']");
1409
+ if (query) {
1410
+ openCombo(query.closest(".combo"), false);
1411
+ return;
1412
+ }
1413
+ if (!target.closest(".combo")) closeCombos();
1091
1414
  var triggerToggle = target.closest("[data-trigger-toggle]");
1092
1415
  if (triggerToggle) {
1093
1416
  toggleTrigger(triggerToggle);
@@ -1107,10 +1430,30 @@
1107
1430
  setSource(sourceBtn.getAttribute("data-source") || "prompt");
1108
1431
  return;
1109
1432
  }
1433
+ var flowPick = target.closest("#workflow-pick [data-workflow]");
1434
+ if (flowPick) {
1435
+ var flows = document.querySelectorAll("#workflow-pick [data-workflow]");
1436
+ for (var f = 0; f < flows.length; f++) flows[f].setAttribute("aria-pressed", flows[f] === flowPick ? "true" : "false");
1437
+ var flowHost = document.getElementById("workflow-pick");
1438
+ if (flowHost) flowHost.setAttribute("data-workflow", flowPick.getAttribute("data-workflow") || "");
1439
+ syncValidity();
1440
+ return;
1441
+ }
1110
1442
  var pick = target.closest("#repo-pick .pick");
1111
1443
  if (pick) {
1112
1444
  var picks = document.querySelectorAll("#repo-pick .pick");
1113
1445
  for (var p = 0; p < picks.length; p++) picks[p].setAttribute("aria-pressed", picks[p] === pick ? "true" : "false");
1446
+ var repoBox = document.getElementById("repo-pick");
1447
+ if (repoBox) {
1448
+ repoBox.setAttribute("data-value", pick.getAttribute("data-repo") || "");
1449
+ repoBox.setAttribute("data-path", pick.getAttribute("data-path") || "");
1450
+ }
1451
+ syncValidity();
1452
+ return;
1453
+ }
1454
+ var themeChoice = target.closest("#theme-pick [data-theme-choice]");
1455
+ if (themeChoice) {
1456
+ chooseTheme(themeChoice.getAttribute("data-theme-choice") || "");
1114
1457
  return;
1115
1458
  }
1116
1459
  var settingsTab = target.closest("#settings-form [data-settings-tab]");
@@ -1173,6 +1516,7 @@
1173
1516
  if (fsButton.classList.contains("fs-use")) {
1174
1517
  ticketsDir = next;
1175
1518
  setPicked(next);
1519
+ syncValidity();
1176
1520
  return;
1177
1521
  }
1178
1522
  loadFs(next, false);
@@ -1186,8 +1530,19 @@
1186
1530
  return;
1187
1531
  }
1188
1532
  if (target.closest("#notify")) {
1189
- if (typeof Notification === "undefined" || Notification.permission !== "default") return;
1190
- Notification.requestPermission().then(notifyLabel);
1533
+ var mode = notifyMode();
1534
+ if (mode === "unavailable" || mode === "blocked") return;
1535
+ if (mode === "on") {
1536
+ setNotifyPref("off");
1537
+ renderNotify();
1538
+ return;
1539
+ }
1540
+ if (mode === "off") {
1541
+ setNotifyPref("on");
1542
+ renderNotify();
1543
+ return;
1544
+ }
1545
+ askNotify();
1191
1546
  return;
1192
1547
  }
1193
1548
  var act = target.closest("[data-act]");
@@ -1245,6 +1600,33 @@
1245
1600
  });
1246
1601
 
1247
1602
  document.addEventListener("keydown", function (event) {
1603
+ var keyTarget = event.target;
1604
+ if (keyTarget && keyTarget.getAttribute && keyTarget.getAttribute("role") === "combobox") {
1605
+ var combo = keyTarget.closest(".combo");
1606
+ var list = combo ? combo.querySelector("[role='listbox']") : null;
1607
+ var comboOpen = list && !list.hidden;
1608
+ if (combo && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
1609
+ event.preventDefault();
1610
+ if (!comboOpen) openCombo(combo, false);
1611
+ else setComboActive(combo, Number(combo.getAttribute("data-active") || "0") + (event.key === "ArrowDown" ? 1 : -1));
1612
+ return;
1613
+ }
1614
+ if (combo && event.key === "Enter") {
1615
+ event.preventDefault();
1616
+ if (comboOpen) {
1617
+ var choices = visibleOptions(combo);
1618
+ var index = Number(combo.getAttribute("data-active") || "0");
1619
+ if (choices[index]) chooseComboOption(combo, choices[index]);
1620
+ }
1621
+ return;
1622
+ }
1623
+ if (combo && event.key === "Escape" && comboOpen) {
1624
+ event.preventDefault();
1625
+ event.stopPropagation();
1626
+ closeCombo(combo);
1627
+ return;
1628
+ }
1629
+ }
1248
1630
  if (event.key === "Escape") closeDrawer();
1249
1631
  var el = event.target;
1250
1632
  if ((event.key === "Enter" || event.key === " ") && el && el.classList && el.classList.contains("card")) {
@@ -1284,7 +1666,8 @@
1284
1666
  var toggle = document.getElementById("pause-toggle");
1285
1667
  var form = document.getElementById("pause-form");
1286
1668
  if (toggle && form && data && typeof data.paused === "boolean") {
1287
- toggle.textContent = data.paused ? "Resume" : "Pause";
1669
+ var pauseLabel = document.getElementById("pause-label");
1670
+ if (pauseLabel) pauseLabel.textContent = data.paused ? "Resume" : "Pause";
1288
1671
  toggle.setAttribute("aria-pressed", data.paused ? "true" : "false");
1289
1672
  form.setAttribute("action", data.paused ? "/api/resume" : "/api/pause");
1290
1673
  var label = document.getElementById("run-label");
@@ -1320,7 +1703,48 @@
1320
1703
  var issueInput = document.getElementById("job-issue");
1321
1704
  if (issueInput) issueInput.addEventListener("input", function () { issueDirty = true; });
1322
1705
  var jiraKey = document.getElementById("jira-key");
1323
- if (jiraKey) jiraKey.addEventListener("input", syncIssueDefault);
1706
+ if (jiraKey) jiraKey.addEventListener("input", function () {
1707
+ syncIssueDefault();
1708
+ syncValidity();
1709
+ });
1710
+ document.addEventListener("input", function (event) {
1711
+ var field = event.target;
1712
+ if (!field || !field.getAttribute) return;
1713
+ if (field.getAttribute("role") === "combobox") {
1714
+ openCombo(field.closest(".combo"), true);
1715
+ return;
1716
+ }
1717
+ if (field.id === "prompt-text" || field.id === "workflow") syncValidity();
1718
+ });
1719
+ function intakeFieldName(target) {
1720
+ if (!target || !target.closest || !target.closest("#add-form")) return "";
1721
+ if (target.id === "prompt-text") return "prompt";
1722
+ if (target.id === "jira-key") return "jira";
1723
+ if (target.id === "workflow" || target.closest("#workflow-pick")) return "workflow";
1724
+ if (target.id === "repo-pick-query" || target.closest("#repo-pick")) return "repo";
1725
+ if (target.closest("#browser-field")) return "source";
1726
+ return "";
1727
+ }
1728
+
1729
+ document.addEventListener("focusout", function (event) {
1730
+ var name = intakeFieldName(event.target);
1731
+ if (!name) return;
1732
+ var next = event.relatedTarget;
1733
+ var stay = { prompt: "#prompt-text", jira: "#jira-key", workflow: "#workflow-field", repo: "#repo-field", source: "#browser-field" };
1734
+ if (next && next.closest && next.closest(stay[name])) return;
1735
+ intakeTouched[name] = true;
1736
+ syncValidity();
1737
+ });
1738
+ document.addEventListener("focusin", function (event) {
1739
+ var field = event.target;
1740
+ if (!field || !field.getAttribute || field.getAttribute("role") !== "combobox") return;
1741
+ openCombo(field.closest(".combo"), false);
1742
+ if (field.select) field.select();
1743
+ });
1744
+ document.addEventListener("change", function (event) {
1745
+ var field = event.target;
1746
+ if (field && field.id === "workflow") syncValidity();
1747
+ });
1324
1748
  var addForm = document.getElementById("add-form");
1325
1749
  if (addForm) addForm.addEventListener("submit", function (event) {
1326
1750
  event.preventDefault();
@@ -1396,8 +1820,9 @@
1396
1820
  }
1397
1821
 
1398
1822
  showFresh();
1823
+ syncValidity();
1399
1824
  renderCompact();
1400
- notifyLabel();
1825
+ renderNotify();
1401
1826
  applyRepo();
1402
1827
  stamp(true);
1403
1828
  paintUnseen();