@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.css +993 -310
- package/assets/serve.js +485 -60
- package/dist/config.js +3 -1
- package/dist/icons.js +41 -0
- package/dist/schemas.js +19 -0
- package/dist/serve-page.js +121 -45
- package/dist/serve.js +19 -18
- package/dist/settings.js +7 -0
- package/dist/theme.js +35 -0
- package/package.json +1 -1
package/dist/config.js
CHANGED
|
@@ -3,6 +3,7 @@ import { homedir } from "node:os";
|
|
|
3
3
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
4
|
import { KilnConfigError } from "./errors.js";
|
|
5
5
|
import { DEFAULT_MAX_ATTEMPTS, DEFAULT_MAX_FIX_ROUNDS, DEFAULT_MAX_SAME_FINGERPRINT, DEFAULT_STALL_TIMEOUT_MS, KilnConfigFileSchema, REPO_NAME, } from "./schemas.js";
|
|
6
|
+
import { resolveBoardTheme } from "./theme.js";
|
|
6
7
|
const CONFIG_NAME = "kiln.config.json";
|
|
7
8
|
export function loadKilnConfig(configPath, options = {}) {
|
|
8
9
|
const home = options.home ?? homedir();
|
|
@@ -60,6 +61,7 @@ export function loadKilnConfig(configPath, options = {}) {
|
|
|
60
61
|
...(parsed.data.reviewPrompt
|
|
61
62
|
? { reviewPrompt: resolveConfiguredPath(configDir, parsed.data.reviewPrompt, home) }
|
|
62
63
|
: {}),
|
|
64
|
+
boardTheme: resolveBoardTheme(parsed.data.board?.theme),
|
|
63
65
|
};
|
|
64
66
|
}
|
|
65
67
|
/**
|
|
@@ -230,7 +232,7 @@ function assertNoStaleCwdConfig(cwd, homeConfig) {
|
|
|
230
232
|
}
|
|
231
233
|
function readConfigObject(configPath) {
|
|
232
234
|
if (!existsSync(configPath))
|
|
233
|
-
return { repos: {} };
|
|
235
|
+
return { repos: {}, board: { theme: "kiln" } };
|
|
234
236
|
let raw;
|
|
235
237
|
try {
|
|
236
238
|
raw = JSON.parse(readFileSync(configPath, "utf8"));
|
package/dist/icons.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Board icons from Lucide 1.70.0 (ISC, copyright Lucide Icons and Contributors).
|
|
3
|
+
* https://lucide.dev — https://github.com/lucide-icons/lucide
|
|
4
|
+
*
|
|
5
|
+
* These are the official single-stroke paths. Size, stroke, and color come from
|
|
6
|
+
* CSS (`.icon`: `--icon`, `--icon-stroke`, `currentColor`). No fill.
|
|
7
|
+
*/
|
|
8
|
+
export const ICONS = {
|
|
9
|
+
pipeline: `<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/><path d="M15 3v18"/>`,
|
|
10
|
+
inbox: `<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>`,
|
|
11
|
+
hand: `<path d="M11 12h2a2 2 0 1 0 0-4h-3c-.6 0-1.1.2-1.4.6L3 14"/><path d="m7 18 1.6-1.4c.3-.4.8-.6 1.4-.6h4c1.1 0 2.1-.4 2.8-1.2l4.6-4.4a2 2 0 0 0-2.75-2.91l-4.2 3.9"/><path d="m2 13 6 6"/>`,
|
|
12
|
+
repos: `<path d="M18 19a5 5 0 0 1-5-5v8"/><path d="M9 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v5"/><circle cx="13" cy="12" r="2"/><circle cx="20" cy="19" r="2"/>`,
|
|
13
|
+
history: `<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>`,
|
|
14
|
+
stats: `<path d="M3 3v16a2 2 0 0 0 2 2h16"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/>`,
|
|
15
|
+
today: `<path d="M8 2v3"/><path d="M16 2v3"/><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/>`,
|
|
16
|
+
triggers: `<path d="M15.914 4a1.5 1.5 0 00-2.474-1.561l-9 9A1.5 1.5 0 005.5 14h4.002a.5.5 0 01.471.666L8.086 20a1.5 1.5 0 002.475 1.56l9-9A1.5 1.5 0 0018.5 10h-3.997a.5.5 0 01-.472-.667z"/>`,
|
|
17
|
+
settings: `<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/>`,
|
|
18
|
+
pause: `<rect x="14" y="3" width="5" height="18" rx="1"/><rect x="5" y="3" width="5" height="18" rx="1"/>`,
|
|
19
|
+
play: `<path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z"/>`,
|
|
20
|
+
bell: `<path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/>`,
|
|
21
|
+
"bell-off": `<path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M17 17H4a1 1 0 0 1-.74-1.673C4.59 13.956 6 12.499 6 8a6 6 0 0 1 .258-1.742"/><path d="m2 2 20 20"/><path d="M8.668 3.01A6 6 0 0 1 18 8c0 2.687.77 4.653 1.707 6.05"/>`,
|
|
22
|
+
};
|
|
23
|
+
/** Lucide names for the marks above. Repo sublinks stay text and are not listed. */
|
|
24
|
+
export const ICON_LUCIDE = {
|
|
25
|
+
pipeline: "columns-3",
|
|
26
|
+
inbox: "inbox",
|
|
27
|
+
hand: "hand-helping",
|
|
28
|
+
repos: "folder-git-2",
|
|
29
|
+
history: "history",
|
|
30
|
+
stats: "chart-column",
|
|
31
|
+
today: "calendar",
|
|
32
|
+
triggers: "zap",
|
|
33
|
+
settings: "settings",
|
|
34
|
+
pause: "pause",
|
|
35
|
+
play: "play",
|
|
36
|
+
bell: "bell",
|
|
37
|
+
"bell-off": "bell-off",
|
|
38
|
+
};
|
|
39
|
+
export function icon(name, className = "icon") {
|
|
40
|
+
return `<svg class="${className}" viewBox="0 0 24 24" aria-hidden="true">${ICONS[name]}</svg>`;
|
|
41
|
+
}
|
package/dist/schemas.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { BOARD_THEME_IDS } from "./theme.js";
|
|
2
3
|
/**
|
|
3
4
|
* Kiln config file. `repos` is an open map: any name, not a fixed set.
|
|
4
5
|
* `path` is absolute, `~`, or (in older files) relative to the config directory.
|
|
@@ -179,6 +180,21 @@ export const TriggerToggleSchema = z
|
|
|
179
180
|
enabled: z.boolean(),
|
|
180
181
|
})
|
|
181
182
|
.strict();
|
|
183
|
+
/** Known board theme ids. An unknown id in a write is rejected. */
|
|
184
|
+
export const BoardThemeSchema = z.enum(BOARD_THEME_IDS);
|
|
185
|
+
/**
|
|
186
|
+
* `board` on disk. `theme` is free text so a typo does not make the whole
|
|
187
|
+
* file invalid; readers fall back to `kiln`.
|
|
188
|
+
*/
|
|
189
|
+
export const BoardFileSchema = z.object({
|
|
190
|
+
theme: z.string().optional(),
|
|
191
|
+
});
|
|
192
|
+
/** `board` on PUT /api/settings. Only a known theme id is written. */
|
|
193
|
+
export const BoardWriteSchema = z
|
|
194
|
+
.object({
|
|
195
|
+
theme: BoardThemeSchema,
|
|
196
|
+
})
|
|
197
|
+
.strict();
|
|
182
198
|
export const EditableKilnSettingsSchema = z.object({
|
|
183
199
|
repos: KilnReposSchema,
|
|
184
200
|
stallTimeoutMs,
|
|
@@ -188,6 +204,7 @@ export const EditableKilnSettingsSchema = z.object({
|
|
|
188
204
|
dailyDollarCap,
|
|
189
205
|
sandbox: EditableSandboxSchema.optional(),
|
|
190
206
|
triggers: z.array(TriggerToggleSchema).optional(),
|
|
207
|
+
board: BoardWriteSchema.optional(),
|
|
191
208
|
});
|
|
192
209
|
/** PUT /api/settings body. `revision` is the sha256 of the file the client loaded. */
|
|
193
210
|
export const SettingsWriteSchema = EditableKilnSettingsSchema.extend({
|
|
@@ -216,6 +233,8 @@ export const KilnConfigFileSchema = z.object({
|
|
|
216
233
|
* Omitted uses the packaged `prompts/kiln/review.md`.
|
|
217
234
|
*/
|
|
218
235
|
reviewPrompt: z.string().min(1).optional(),
|
|
236
|
+
/** Board appearance. Omitted theme means Kiln. */
|
|
237
|
+
board: BoardFileSchema.optional(),
|
|
219
238
|
});
|
|
220
239
|
/**
|
|
221
240
|
* Where a job came from. Adapters set this; tick and reconcile do not branch on it.
|
package/dist/serve-page.js
CHANGED
|
@@ -3,9 +3,11 @@ import { join } from "node:path";
|
|
|
3
3
|
import { packageRoot } from "./paths.js";
|
|
4
4
|
import { buildBoard, } from "./board-view.js";
|
|
5
5
|
import { COLUMN_LABEL, PIPELINE_COLUMNS, formatCost, formatDuration, formatLocal, formatRelative, shortSha, stateTone, } from "./job-view.js";
|
|
6
|
+
import { icon } from "./icons.js";
|
|
6
7
|
import { renderMarkdown } from "./markdown.js";
|
|
7
8
|
import { kilnVersion } from "./project.js";
|
|
8
9
|
import { formatSpend } from "./rails.js";
|
|
10
|
+
import { BOARD_THEME_CHOICES, DEFAULT_BOARD_THEME } from "./theme.js";
|
|
9
11
|
import { listWorkflowNames } from "./workflow.js";
|
|
10
12
|
const LIVE_SCRIPT = readFileSync(join(packageRoot(), "assets", "serve.js"), "utf8");
|
|
11
13
|
export function renderConsole(css, attention, page) {
|
|
@@ -23,16 +25,23 @@ export function renderApp(model) {
|
|
|
23
25
|
const capNote = page.spend?.blocked ? `<span class="cap" id="cap-note">cap hit</span>` : `<span class="cap" id="cap-note" hidden>cap hit</span>`;
|
|
24
26
|
const body = `<div class="shell">
|
|
25
27
|
<aside class="nav" aria-label="Kiln">
|
|
26
|
-
<div class="brand"><span class="logo" aria-hidden="true"><svg
|
|
28
|
+
<div class="brand"><span class="logo" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12V6l5-3 5 3v6l-5 3-5-3z"/><path d="M3 6l5 3 5-3M8 9v6"/></svg></span><div><h1>Kiln</h1>${version}</div></div>
|
|
29
|
+
<div class="nav-scroll">
|
|
30
|
+
${navHtml(model)}
|
|
31
|
+
<button class="add" type="button" id="add-job">Add job</button>
|
|
32
|
+
</div>
|
|
33
|
+
<footer class="side-foot">
|
|
27
34
|
<div class="runstate" id="runstate">
|
|
28
35
|
<span class="live-dot${paused ? " paused" : ""}" id="run-dot" aria-hidden="true"></span>
|
|
29
36
|
<span id="run-label">${paused ? "Paused" : "Running"}</span>
|
|
30
|
-
<form method="post" action="${paused ? "/api/resume" : "/api/pause"}" class="pause" id="pause-form"><input type="hidden" name="csrf" value="${escapeHtml(page.csrf)}"><button type="submit" id="pause-toggle" aria-pressed="${paused ? "true" : "false"}">${paused ? "Resume" : "Pause"}</button></form>
|
|
37
|
+
<form method="post" action="${paused ? "/api/resume" : "/api/pause"}" class="pause" id="pause-form"><input type="hidden" name="csrf" value="${escapeHtml(page.csrf)}"><button type="submit" id="pause-toggle" aria-pressed="${paused ? "true" : "false"}">${icon("pause", "icon icon-pause")}${icon("play", "icon icon-play")}<span id="pause-label">${paused ? "Resume" : "Pause"}</span></button></form>
|
|
31
38
|
</div>
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
<button class="
|
|
35
|
-
<
|
|
39
|
+
<p class="spend-row">${spend}${capNote}</p>
|
|
40
|
+
<div class="notify">
|
|
41
|
+
<button class="tool-link" type="button" id="notify" data-state="ask" aria-pressed="false">${icon("bell", "icon icon-bell")}${icon("bell-off", "icon icon-bell-off")}<span id="notify-label">Notify me</span></button>
|
|
42
|
+
<p class="notify-note" id="notify-note" hidden></p>
|
|
43
|
+
</div>
|
|
44
|
+
</footer>
|
|
36
45
|
</aside>
|
|
37
46
|
<div class="workspace${model.section === "job" ? " with-rail" : ""}">
|
|
38
47
|
${mainHtml(model, page)}
|
|
@@ -50,31 +59,34 @@ ${intakeSheet(page, model.attention)}
|
|
|
50
59
|
revision: model.attention.revision,
|
|
51
60
|
csrf: page.csrf,
|
|
52
61
|
section: model.section,
|
|
62
|
+
theme: page.theme ?? DEFAULT_BOARD_THEME,
|
|
53
63
|
...(job ? { jobId: job.jobId, latest: job.latestEventAt } : {}),
|
|
54
64
|
live: true,
|
|
55
65
|
body,
|
|
56
66
|
});
|
|
57
67
|
}
|
|
58
|
-
export function renderTextPage(css, title, text) {
|
|
68
|
+
export function renderTextPage(css, title, text, theme = DEFAULT_BOARD_THEME) {
|
|
59
69
|
return document({
|
|
60
70
|
css,
|
|
61
71
|
title,
|
|
62
72
|
needsYou: 0,
|
|
63
73
|
revision: "",
|
|
64
74
|
section: "job",
|
|
75
|
+
theme,
|
|
65
76
|
live: false,
|
|
66
77
|
body: `<a class="back" href="/">Pipeline</a>
|
|
67
78
|
<div class="page"><h1>${escapeHtml(title)}</h1>
|
|
68
79
|
<pre class="text">${escapeHtml(text)}</pre></div>`,
|
|
69
80
|
});
|
|
70
81
|
}
|
|
71
|
-
export function renderMessagePage(css, title, message) {
|
|
82
|
+
export function renderMessagePage(css, title, message, theme = DEFAULT_BOARD_THEME) {
|
|
72
83
|
return document({
|
|
73
84
|
css,
|
|
74
85
|
title,
|
|
75
86
|
needsYou: 0,
|
|
76
87
|
revision: "",
|
|
77
88
|
section: "job",
|
|
89
|
+
theme,
|
|
78
90
|
live: false,
|
|
79
91
|
body: `<a class="back" href="/">Pipeline</a>
|
|
80
92
|
<div class="page"><h1>${escapeHtml(title)}</h1>
|
|
@@ -83,11 +95,11 @@ export function renderMessagePage(css, title, message) {
|
|
|
83
95
|
}
|
|
84
96
|
function navHtml(model) {
|
|
85
97
|
const board = model.board;
|
|
86
|
-
const item = (href, id, label, section, badge) => {
|
|
98
|
+
const item = (href, id, label, section, glyph, badge) => {
|
|
87
99
|
const current = model.section === section ? ` aria-current="page"` : "";
|
|
88
100
|
const count = badge !== undefined && badge > 0 ? `<span class="badge">${badge}</span>` : "";
|
|
89
101
|
const extra = id === "open-settings" ? ` id="open-settings"` : "";
|
|
90
|
-
return `<a class="nav-link" href="${href}"${current}${extra}>${escapeHtml(label)}
|
|
102
|
+
return `<a class="nav-link" href="${href}"${current}${extra}><span class="nav-label">${icon(glyph)}${escapeHtml(label)}</span>${count}</a>`;
|
|
91
103
|
};
|
|
92
104
|
const repos = board.repos
|
|
93
105
|
.map((group) => {
|
|
@@ -96,23 +108,23 @@ function navHtml(model) {
|
|
|
96
108
|
})
|
|
97
109
|
.join("");
|
|
98
110
|
return `<nav class="side" aria-label="Sections">
|
|
99
|
-
${item("/", "pipeline", "Pipeline", "pipeline")}
|
|
100
|
-
${item("/inbox", "inbox", "Inbox", "inbox", board.inboxCount)}
|
|
111
|
+
${item("/", "pipeline", "Pipeline", "pipeline", "pipeline")}
|
|
112
|
+
${item("/inbox", "inbox", "Inbox", "inbox", "inbox", board.inboxCount)}
|
|
101
113
|
<p class="unseen" id="inbox-unseen" hidden></p>
|
|
102
|
-
${item("/hand", "hand", "Needs a hand", "hand", board.handCount)}
|
|
103
|
-
${item("/repos", "repos", "Repos", "repos")}
|
|
114
|
+
${item("/hand", "hand", "Needs a hand", "hand", "hand", board.handCount)}
|
|
115
|
+
${item("/repos", "repos", "Repos", "repos", "repos")}
|
|
104
116
|
${repos}
|
|
105
|
-
${item("/history", "history", "History", "history", board.history.length)}
|
|
106
|
-
${item("/stats", "stats", "Stats", "stats")}
|
|
107
|
-
${item("/report", "report", "Today", "report")}
|
|
108
|
-
${item("/triggers", "triggers", "Triggers", "triggers")}
|
|
109
|
-
${item("/settings", "open-settings", "Settings", "settings")}
|
|
117
|
+
${item("/history", "history", "History", "history", "history", board.history.length)}
|
|
118
|
+
${item("/stats", "stats", "Stats", "stats", "stats")}
|
|
119
|
+
${item("/report", "report", "Today", "report", "today")}
|
|
120
|
+
${item("/triggers", "triggers", "Triggers", "triggers", "triggers")}
|
|
121
|
+
${item("/settings", "open-settings", "Settings", "settings", "settings")}
|
|
110
122
|
</nav>`;
|
|
111
123
|
}
|
|
112
124
|
function mainHtml(model, page) {
|
|
113
125
|
const error = `<p class="meta config-error" id="config-error" role="status"${page.configError ? "" : " hidden"}>${escapeHtml(page.configError ?? "")}</p>`;
|
|
114
126
|
if (model.section === "settings") {
|
|
115
|
-
return `${error}<main class="main"><h2>Settings</h2><section id="settings" aria-label="Settings">${settingsForm()}</section></main>`;
|
|
127
|
+
return `${error}<main class="main"><h2>Settings</h2><section id="settings" aria-label="Settings">${settingsForm(page.theme ?? DEFAULT_BOARD_THEME)}</section></main>`;
|
|
116
128
|
}
|
|
117
129
|
if (model.section === "stats" && model.stats) {
|
|
118
130
|
return `${error}<main class="main">${statsHtml(model.stats)}</main>`;
|
|
@@ -263,7 +275,7 @@ function jobRow(job, section, csrf) {
|
|
|
263
275
|
classes.push("retrying");
|
|
264
276
|
if (section === "inbox" || (section === "hand" && !job.scheduledRetry))
|
|
265
277
|
classes.push("human");
|
|
266
|
-
const flag = classes.includes("human") ? `<span class="flag">
|
|
278
|
+
const flag = classes.includes("human") ? `<span class="flag">Needs a decision</span>` : "";
|
|
267
279
|
const repo = section === "repos" ? "" : `<span class="meta">${escapeHtml(job.repo)}</span>`;
|
|
268
280
|
const sandbox = job.sandboxed ? `<span class="meta">sandbox · docker</span>` : "";
|
|
269
281
|
const pr = section === "repos" && job.state === "in-review" ? `<span class="meta">PR review</span>` : "";
|
|
@@ -651,6 +663,8 @@ function reportHtml(report) {
|
|
|
651
663
|
</div>
|
|
652
664
|
<section class="stat"><h3>What ran</h3>${runs}</section>`;
|
|
653
665
|
}
|
|
666
|
+
/** Chips wrap up to this many choices. Past it, use a select or combobox. */
|
|
667
|
+
const PICK_CHIP_MAX = 5;
|
|
654
668
|
function tableRows(rows, empty) {
|
|
655
669
|
if (rows.length === 0)
|
|
656
670
|
return `<p class="empty">${escapeHtml(empty)}</p>`;
|
|
@@ -668,31 +682,50 @@ function intakeSheet(intake, attention) {
|
|
|
668
682
|
<button type="button" class="close" data-close aria-label="Close">×</button>
|
|
669
683
|
</div>
|
|
670
684
|
<form id="add-form" class="intake-form">
|
|
671
|
-
<div class="
|
|
672
|
-
<
|
|
673
|
-
<
|
|
685
|
+
<div class="intake-body">
|
|
686
|
+
<div class="field" id="repo-field"><span class="k" id="repo-label">Repo</span>${repoPicker(intake.repos)}
|
|
687
|
+
<p class="field-error" id="repo-error" role="alert" hidden></p></div>
|
|
688
|
+
<div class="field" id="workflow-field"><span class="k" id="workflow-label">Workflow</span>${workflowPicker(intake.workflows)}
|
|
689
|
+
<p class="field-error" id="workflow-error" role="alert" hidden></p></div>
|
|
690
|
+
<div class="field"><span class="k" id="source-label">Source</span>
|
|
691
|
+
<div class="seg" role="group" aria-labelledby="source-label">
|
|
674
692
|
<button type="button" data-source="prompt" aria-pressed="true">Prompt</button>
|
|
675
693
|
<button type="button" data-source="spec" aria-pressed="false">Spec file</button>
|
|
676
694
|
<button type="button" data-source="tickets" aria-pressed="false">Tickets folder</button>
|
|
677
695
|
<button type="button" data-source="jira" aria-pressed="false">Jira</button>
|
|
678
|
-
</div>
|
|
679
|
-
<div data-pane="prompt"><label class="field"><span class="k">Prompt</span><textarea id="prompt-text" rows="4" placeholder="What should this job do?"></textarea
|
|
680
|
-
<
|
|
696
|
+
</div></div>
|
|
697
|
+
<div data-pane="prompt"><label class="field"><span class="k">Prompt</span><textarea id="prompt-text" rows="4" placeholder="What should this job do?" aria-describedby="prompt-error"></textarea>
|
|
698
|
+
<p class="field-error" id="prompt-error" role="alert" hidden></p></label></div>
|
|
699
|
+
<div class="field" id="browser-field" hidden>
|
|
681
700
|
<div class="k" id="browser-label">Spec file</div>
|
|
682
701
|
<div class="fs" id="browser"></div>
|
|
683
702
|
<p class="meta" id="picked"></p>
|
|
703
|
+
<p class="field-error" id="source-error" role="alert" hidden></p>
|
|
684
704
|
</div>
|
|
685
|
-
<div data-pane="jira" hidden><label class="field"><span class="k">Jira key</span><input id="jira-key" type="text" placeholder="PROJ-123" autocomplete="off" spellcheck="false"
|
|
705
|
+
<div data-pane="jira" hidden><label class="field"><span class="k">Jira key</span><input id="jira-key" type="text" placeholder="PROJ-123" autocomplete="off" spellcheck="false" aria-describedby="jira-error">
|
|
706
|
+
<p class="field-error" id="jira-error" role="alert" hidden></p></label></div>
|
|
686
707
|
<label class="field" id="issue-field"><span class="k">Issue</span><input id="job-issue" type="text" placeholder="ABC-123" autocomplete="off" spellcheck="false"></label>
|
|
687
708
|
<label class="field" id="title-field"><span class="k">Title</span><input id="job-title" type="text" autocomplete="off"></label>
|
|
688
709
|
<fieldset class="field" id="after-field"><legend class="k">After</legend><div id="after-list">${afterOptions(items.map((item) => item.job))}</div></fieldset>
|
|
689
710
|
<pre class="text preview" id="intake-preview" hidden></pre>
|
|
690
711
|
<p class="form-error" id="intake-error" role="alert" hidden></p>
|
|
691
|
-
|
|
712
|
+
</div>
|
|
713
|
+
<div class="form-footer">
|
|
714
|
+
<button type="button" class="btn" id="preview-job" hidden>Preview</button>
|
|
715
|
+
<button type="button" class="btn" data-close>Cancel</button>
|
|
716
|
+
<button type="submit" class="btn primary" id="add-submit" disabled>Add</button>
|
|
717
|
+
</div>
|
|
692
718
|
</form>
|
|
693
719
|
</section>`;
|
|
694
720
|
}
|
|
695
|
-
function
|
|
721
|
+
function themePicker(selected) {
|
|
722
|
+
const buttons = BOARD_THEME_CHOICES.map((theme) => {
|
|
723
|
+
const on = theme.id === selected;
|
|
724
|
+
return `<button type="button" class="theme" data-theme-choice="${theme.id}" aria-pressed="${on ? "true" : "false"}"><span class="theme-swatch" data-theme="${theme.id}" aria-hidden="true"><span class="swatch bg"></span><span class="swatch panel"></span><span class="swatch card"></span><span class="swatch accent"></span></span><span class="theme-label">${escapeHtml(theme.label)}</span></button>`;
|
|
725
|
+
}).join("");
|
|
726
|
+
return `<div class="themes" id="theme-pick" role="group" aria-label="Theme">${buttons}</div>`;
|
|
727
|
+
}
|
|
728
|
+
function settingsForm(theme) {
|
|
696
729
|
return `<form id="settings-form" class="intake-form">
|
|
697
730
|
<p class="meta" id="settings-path"></p>
|
|
698
731
|
<p class="saved" id="settings-saved" role="status" hidden>Saved</p>
|
|
@@ -700,6 +733,7 @@ function settingsForm() {
|
|
|
700
733
|
<div class="seg" role="tablist" aria-label="Settings sections">
|
|
701
734
|
<button type="button" data-settings-tab="repos" aria-pressed="true">Repos</button>
|
|
702
735
|
<button type="button" data-settings-tab="global" aria-pressed="false">Global</button>
|
|
736
|
+
<button type="button" data-settings-tab="appearance" aria-pressed="false">Appearance</button>
|
|
703
737
|
</div>
|
|
704
738
|
<div id="settings-repos">
|
|
705
739
|
<div id="repo-editor" hidden>
|
|
@@ -760,29 +794,70 @@ function settingsForm() {
|
|
|
760
794
|
<p class="hint" id="hint-cap"></p>
|
|
761
795
|
<p class="field-error" data-error="dailyDollarCap" hidden></p>
|
|
762
796
|
</div>
|
|
763
|
-
<div
|
|
797
|
+
<div id="settings-appearance" hidden>
|
|
798
|
+
<p class="hint">Color palette for this board. The choice applies immediately and is saved here.</p>
|
|
799
|
+
${themePicker(theme)}
|
|
800
|
+
<p class="field-error" data-error="board.theme" hidden></p>
|
|
801
|
+
</div>
|
|
802
|
+
<div class="form-footer"><button type="submit" class="btn primary">Save</button></div>
|
|
764
803
|
</form>`;
|
|
765
804
|
}
|
|
766
|
-
function
|
|
767
|
-
|
|
768
|
-
return `<p class="empty">No repos are registered.</p>`;
|
|
769
|
-
return repos
|
|
770
|
-
.map((repo, index) => `<button type="button" class="pick" data-repo="${escapeHtml(repo.name)}" data-path="${escapeHtml(repo.path)}" aria-pressed="${index === 0 ? "true" : "false"}">${escapeHtml(repo.name)}</button>`)
|
|
771
|
-
.join("");
|
|
805
|
+
function repoListKey(repos) {
|
|
806
|
+
return repos.map((repo) => `${repo.name}\t${repo.path}`).join("\n");
|
|
772
807
|
}
|
|
773
|
-
function
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
808
|
+
function repoPicker(repos) {
|
|
809
|
+
const list = escapeHtml(repoListKey(repos));
|
|
810
|
+
if (repos.length === 0) {
|
|
811
|
+
return `<div class="picks" id="repo-pick" data-mode="empty" data-value="" data-path="" data-list="${list}" role="group" aria-labelledby="repo-label"><p class="empty">No repos are registered.</p></div>`;
|
|
812
|
+
}
|
|
813
|
+
const selected = repos[0];
|
|
814
|
+
if (!selected) {
|
|
815
|
+
return `<div class="picks" id="repo-pick" data-mode="empty" data-value="" data-path="" data-list="" role="group" aria-labelledby="repo-label"><p class="empty">No repos are registered.</p></div>`;
|
|
816
|
+
}
|
|
817
|
+
if (repos.length > PICK_CHIP_MAX) {
|
|
818
|
+
const options = repos
|
|
819
|
+
.map((repo, index) => {
|
|
820
|
+
const on = repo.name === selected.name;
|
|
821
|
+
return `<li role="option" id="repo-opt-${index}" data-value="${escapeHtml(repo.name)}" data-path="${escapeHtml(repo.path)}" aria-selected="${on ? "true" : "false"}">${escapeHtml(repo.name)}</li>`;
|
|
822
|
+
})
|
|
823
|
+
.join("");
|
|
824
|
+
return `<div class="combo" id="repo-pick" data-mode="select" data-value="${escapeHtml(selected.name)}" data-path="${escapeHtml(selected.path)}" data-list="${list}"><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="${escapeHtml(selected.name)}" autocomplete="off" spellcheck="false"><ul class="combo-list" id="repo-pick-list" role="listbox" hidden>${options}<li class="empty" role="presentation" hidden>No matching repos.</li></ul></div>`;
|
|
825
|
+
}
|
|
826
|
+
const buttons = repos
|
|
827
|
+
.map((repo, index) => {
|
|
828
|
+
const on = index === 0;
|
|
829
|
+
return `<button type="button" class="pick" data-repo="${escapeHtml(repo.name)}" data-path="${escapeHtml(repo.path)}" aria-pressed="${on ? "true" : "false"}">${escapeHtml(repo.name)}</button>`;
|
|
830
|
+
})
|
|
778
831
|
.join("");
|
|
832
|
+
return `<div class="picks" id="repo-pick" data-mode="chips" data-value="${escapeHtml(selected.name)}" data-path="${escapeHtml(selected.path)}" data-list="${list}" role="group" aria-labelledby="repo-label">${buttons}</div>`;
|
|
833
|
+
}
|
|
834
|
+
function workflowPicker(names) {
|
|
835
|
+
const selected = names.includes("feature") ? "feature" : (names[0] ?? "");
|
|
836
|
+
if (names.length === 0 || names.length > PICK_CHIP_MAX) {
|
|
837
|
+
const options = names.length === 0
|
|
838
|
+
? `<option value="">none</option>`
|
|
839
|
+
: names
|
|
840
|
+
.map((name) => `<option value="${escapeHtml(name)}"${name === selected ? " selected" : ""}>${escapeHtml(name)}</option>`)
|
|
841
|
+
.join("");
|
|
842
|
+
return `<select id="workflow" name="workflow" aria-labelledby="workflow-label">${options}</select>`;
|
|
843
|
+
}
|
|
844
|
+
const buttons = names
|
|
845
|
+
.map((name) => `<button type="button" data-workflow="${escapeHtml(name)}" aria-pressed="${name === selected ? "true" : "false"}">${escapeHtml(name)}</button>`)
|
|
846
|
+
.join("");
|
|
847
|
+
return `<div class="seg" id="workflow-pick" data-mode="chips" data-workflow="${escapeHtml(selected)}" role="group" aria-labelledby="workflow-label">${buttons}</div>`;
|
|
848
|
+
}
|
|
849
|
+
function afterLabel(job) {
|
|
850
|
+
const title = job.title.trim();
|
|
851
|
+
if (!title || title === job.jobId)
|
|
852
|
+
return job.jobId;
|
|
853
|
+
return `${job.jobId} — ${title}`;
|
|
779
854
|
}
|
|
780
855
|
function afterOptions(jobs) {
|
|
781
856
|
const open = jobs.filter((job) => job.state !== "done");
|
|
782
857
|
if (open.length === 0)
|
|
783
858
|
return `<p class="empty">No open jobs.</p>`;
|
|
784
859
|
return open
|
|
785
|
-
.map((job) => `<label class="after"><input type="checkbox" name="after" value="${escapeHtml(job.jobId)}"><span
|
|
860
|
+
.map((job) => `<label class="after"><input type="checkbox" name="after" value="${escapeHtml(job.jobId)}"><span>${escapeHtml(afterLabel(job))}</span></label>`)
|
|
786
861
|
.join("");
|
|
787
862
|
}
|
|
788
863
|
function safeUrl(url) {
|
|
@@ -801,8 +876,9 @@ function safeUrl(url) {
|
|
|
801
876
|
function document(options) {
|
|
802
877
|
const job = options.jobId ? ` data-job="${escapeHtml(options.jobId)}"` : "";
|
|
803
878
|
const latest = options.latest ? ` data-latest="${escapeHtml(options.latest)}"` : "";
|
|
879
|
+
const theme = options.theme ?? DEFAULT_BOARD_THEME;
|
|
804
880
|
return `<!DOCTYPE html>
|
|
805
|
-
<html lang="en">
|
|
881
|
+
<html lang="en" data-theme="${theme}">
|
|
806
882
|
<head>
|
|
807
883
|
<meta charset="utf-8">
|
|
808
884
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
@@ -812,7 +888,7 @@ function document(options) {
|
|
|
812
888
|
${options.css}
|
|
813
889
|
</style>
|
|
814
890
|
</head>
|
|
815
|
-
<body data-section="${escapeHtml(options.section)}" data-needs-you="${options.needsYou}" data-revision="${escapeHtml(options.revision)}" data-csrf="${escapeHtml(options.csrf ?? "")}"${job}${latest}>
|
|
891
|
+
<body data-section="${escapeHtml(options.section)}" data-needs-you="${options.needsYou}" data-revision="${escapeHtml(options.revision)}" data-csrf="${escapeHtml(options.csrf ?? "")}" data-pick-limit="${PICK_CHIP_MAX}"${job}${latest}>
|
|
816
892
|
${options.body}
|
|
817
893
|
${options.live ? `<script>\n${LIVE_SCRIPT}\n</script>` : ""}
|
|
818
894
|
</body>
|
package/dist/serve.js
CHANGED
|
@@ -271,7 +271,7 @@ async function handle(req, res, state, boundPort, css, csrf) {
|
|
|
271
271
|
if (listed === "stats") {
|
|
272
272
|
const window = parseStatsWindow(url.searchParams.get("window"));
|
|
273
273
|
if (!window) {
|
|
274
|
-
sendHtml(res, 400, renderMessagePage(css, "Stats", "window must be today, 7d, or 30d."), secrets);
|
|
274
|
+
sendHtml(res, 400, renderMessagePage(css, "Stats", "window must be today, 7d, or 30d.", config.boardTheme), secrets);
|
|
275
275
|
return;
|
|
276
276
|
}
|
|
277
277
|
sendHtml(res, 200, renderShell(state, css, csrf, "stats", { window }), secrets);
|
|
@@ -287,11 +287,11 @@ async function handle(req, res, state, boundPort, css, csrf) {
|
|
|
287
287
|
name = decodeURIComponent(repoPage[1]);
|
|
288
288
|
}
|
|
289
289
|
catch {
|
|
290
|
-
sendHtml(res, 404, renderMessagePage(css, "Not found", "Not found."), secrets);
|
|
290
|
+
sendHtml(res, 404, renderMessagePage(css, "Not found", "Not found.", config.boardTheme), secrets);
|
|
291
291
|
return;
|
|
292
292
|
}
|
|
293
293
|
if (!state.config.repos[name]) {
|
|
294
|
-
sendHtml(res, 404, renderMessagePage(css, "Not found", `No repo ${name}
|
|
294
|
+
sendHtml(res, 404, renderMessagePage(css, "Not found", `No repo ${name}.`, config.boardTheme), secrets);
|
|
295
295
|
return;
|
|
296
296
|
}
|
|
297
297
|
sendHtml(res, 200, renderShell(state, css, csrf, "repos", { repo: name }), secrets);
|
|
@@ -302,12 +302,12 @@ async function handle(req, res, state, boundPort, css, csrf) {
|
|
|
302
302
|
const jobId = parseJobId(jobPage[1]);
|
|
303
303
|
const job = jobId ? findJob(config, jobId) : undefined;
|
|
304
304
|
if (!job) {
|
|
305
|
-
sendHtml(res, 404, renderMessagePage(css, "Not found", jobId ? `No job ${jobId}.` : "Not found."), secrets);
|
|
305
|
+
sendHtml(res, 404, renderMessagePage(css, "Not found", jobId ? `No job ${jobId}.` : "Not found.", config.boardTheme), secrets);
|
|
306
306
|
return;
|
|
307
307
|
}
|
|
308
308
|
const tabRaw = url.searchParams.get("tab");
|
|
309
309
|
if (tabRaw && tabRaw !== "overview" && tabRaw !== "changes" && tabRaw !== "timeline") {
|
|
310
|
-
sendHtml(res, 400, renderMessagePage(css, "Job", "tab must be overview, changes, or timeline."), secrets);
|
|
310
|
+
sendHtml(res, 400, renderMessagePage(css, "Job", "tab must be overview, changes, or timeline.", config.boardTheme), secrets);
|
|
311
311
|
return;
|
|
312
312
|
}
|
|
313
313
|
const tab = tabRaw === "changes" || tabRaw === "timeline" ? tabRaw : "overview";
|
|
@@ -319,7 +319,7 @@ async function handle(req, res, state, boundPort, css, csrf) {
|
|
|
319
319
|
const jobId = parseJobId(page[1]);
|
|
320
320
|
const job = jobId ? findJob(config, jobId) : undefined;
|
|
321
321
|
if (!job) {
|
|
322
|
-
sendHtml(res, 404, renderMessagePage(css, "Not found", jobId ? `No job ${jobId}.` : "Not found."), secrets);
|
|
322
|
+
sendHtml(res, 404, renderMessagePage(css, "Not found", jobId ? `No job ${jobId}.` : "Not found.", config.boardTheme), secrets);
|
|
323
323
|
return;
|
|
324
324
|
}
|
|
325
325
|
if (page[2] === "findings") {
|
|
@@ -328,22 +328,22 @@ async function handle(req, res, state, boundPort, css, csrf) {
|
|
|
328
328
|
}
|
|
329
329
|
const target = page[2] === "spec" ? job.specPath : job.logPath;
|
|
330
330
|
if (!target) {
|
|
331
|
-
sendHtml(res, 404, renderMessagePage(css, "Not found", page[2] === "log" ? "No log yet." : "No spec."), secrets);
|
|
331
|
+
sendHtml(res, 404, renderMessagePage(css, "Not found", page[2] === "log" ? "No log yet." : "No spec.", config.boardTheme), secrets);
|
|
332
332
|
return;
|
|
333
333
|
}
|
|
334
334
|
const text = readInside(config.kilnDir, target);
|
|
335
335
|
if (text === undefined) {
|
|
336
|
-
sendHtml(res, 404, renderMessagePage(css, "Not found", "That file is not in the kiln directory."), secrets);
|
|
336
|
+
sendHtml(res, 404, renderMessagePage(css, "Not found", "That file is not in the kiln directory.", config.boardTheme), secrets);
|
|
337
337
|
return;
|
|
338
338
|
}
|
|
339
339
|
const title = page[2] === "spec" ? `${job.title} spec` : `${job.title} log`;
|
|
340
|
-
sendHtml(res, 200, renderTextPage(css, title, text), secrets);
|
|
340
|
+
sendHtml(res, 200, renderTextPage(css, title, text, config.boardTheme), secrets);
|
|
341
341
|
return;
|
|
342
342
|
}
|
|
343
343
|
if (path.startsWith("/api/"))
|
|
344
344
|
sendJson(res, 404, { error: "Not found." }, secrets);
|
|
345
345
|
else
|
|
346
|
-
sendHtml(res, 404, renderMessagePage(css, "Not found", "Not found."), secrets);
|
|
346
|
+
sendHtml(res, 404, renderMessagePage(css, "Not found", "Not found.", config.boardTheme), secrets);
|
|
347
347
|
}
|
|
348
348
|
async function cancelQueued(res, config, secrets, raw) {
|
|
349
349
|
let parsed;
|
|
@@ -393,7 +393,7 @@ async function answer(req, res, config, css, secrets, jobId, choice) {
|
|
|
393
393
|
if (wantsJson(req))
|
|
394
394
|
sendJson(res, status, { error: message }, secrets);
|
|
395
395
|
else
|
|
396
|
-
sendHtml(res, status, renderMessagePage(css, "Could not answer", message), secrets);
|
|
396
|
+
sendHtml(res, status, renderMessagePage(css, "Could not answer", message, config.boardTheme), secrets);
|
|
397
397
|
return;
|
|
398
398
|
}
|
|
399
399
|
const job = findJob(config, jobId);
|
|
@@ -410,7 +410,7 @@ async function revise(req, res, config, css, secrets, jobId, raw) {
|
|
|
410
410
|
if (wantsJson(req))
|
|
411
411
|
sendJson(res, 400, { error: message }, secrets);
|
|
412
412
|
else
|
|
413
|
-
sendHtml(res, 400, renderMessagePage(css, "Could not revise", message), secrets);
|
|
413
|
+
sendHtml(res, 400, renderMessagePage(css, "Could not revise", message, config.boardTheme), secrets);
|
|
414
414
|
return;
|
|
415
415
|
}
|
|
416
416
|
try {
|
|
@@ -422,7 +422,7 @@ async function revise(req, res, config, css, secrets, jobId, raw) {
|
|
|
422
422
|
if (wantsJson(req))
|
|
423
423
|
sendJson(res, status, { error: message }, secrets);
|
|
424
424
|
else
|
|
425
|
-
sendHtml(res, status, renderMessagePage(css, "Could not revise", message), secrets);
|
|
425
|
+
sendHtml(res, status, renderMessagePage(css, "Could not revise", message, config.boardTheme), secrets);
|
|
426
426
|
return;
|
|
427
427
|
}
|
|
428
428
|
const job = findJob(config, jobId);
|
|
@@ -455,7 +455,7 @@ async function retry(req, res, config, css, secrets, jobId) {
|
|
|
455
455
|
if (wantsJson(req))
|
|
456
456
|
sendJson(res, status, { error: message }, secrets);
|
|
457
457
|
else
|
|
458
|
-
sendHtml(res, status, renderMessagePage(css, "Could not retry", message), secrets);
|
|
458
|
+
sendHtml(res, status, renderMessagePage(css, "Could not retry", message, config.boardTheme), secrets);
|
|
459
459
|
return;
|
|
460
460
|
}
|
|
461
461
|
const job = findJob(config, jobId);
|
|
@@ -467,19 +467,19 @@ async function retry(req, res, config, css, secrets, jobId) {
|
|
|
467
467
|
}
|
|
468
468
|
function sendFindings(res, config, css, secrets, job) {
|
|
469
469
|
if (job.workflow !== INVESTIGATION_WORKFLOW) {
|
|
470
|
-
sendHtml(res, 404, renderMessagePage(css, "Not found", "Not found."), secrets);
|
|
470
|
+
sendHtml(res, 404, renderMessagePage(css, "Not found", "Not found.", config.boardTheme), secrets);
|
|
471
471
|
return;
|
|
472
472
|
}
|
|
473
473
|
if (!job.findingsPath) {
|
|
474
|
-
sendHtml(res, 404, renderMessagePage(css, "Not found", "No findings yet."), secrets);
|
|
474
|
+
sendHtml(res, 404, renderMessagePage(css, "Not found", "No findings yet.", config.boardTheme), secrets);
|
|
475
475
|
return;
|
|
476
476
|
}
|
|
477
477
|
const text = readInside(config.kilnDir, job.findingsPath);
|
|
478
478
|
if (text === undefined) {
|
|
479
|
-
sendHtml(res, 404, renderMessagePage(css, "Not found", "That file is not in the kiln directory."), secrets);
|
|
479
|
+
sendHtml(res, 404, renderMessagePage(css, "Not found", "That file is not in the kiln directory.", config.boardTheme), secrets);
|
|
480
480
|
return;
|
|
481
481
|
}
|
|
482
|
-
sendHtml(res, 200, renderTextPage(css, `${job.title} findings`, text), secrets);
|
|
482
|
+
sendHtml(res, 200, renderTextPage(css, `${job.title} findings`, text, config.boardTheme), secrets);
|
|
483
483
|
}
|
|
484
484
|
function findJob(config, jobId) {
|
|
485
485
|
try {
|
|
@@ -753,6 +753,7 @@ function pageOptions(state, csrf) {
|
|
|
753
753
|
paused: isPaused(state.config.kilnDir),
|
|
754
754
|
spend,
|
|
755
755
|
...(state.configError ? { configError: state.configError } : {}),
|
|
756
|
+
theme: state.config.boardTheme,
|
|
756
757
|
};
|
|
757
758
|
}
|
|
758
759
|
function statusDocument(state) {
|
package/dist/settings.js
CHANGED
|
@@ -5,6 +5,7 @@ import { basename, dirname, join } from "node:path";
|
|
|
5
5
|
import { assertBranchName, assertCheckoutDirectory } from "./config.js";
|
|
6
6
|
import { DEFAULT_BRANCH_TEMPLATE, DEFAULT_PR_TITLE } from "./pr-names.js";
|
|
7
7
|
import { DEFAULT_MAX_ATTEMPTS, DEFAULT_MAX_FIX_ROUNDS, DEFAULT_MAX_SAME_FINGERPRINT, DEFAULT_STALL_TIMEOUT_MS, KilnConfigFileSchema, SettingsWriteSchema, TriggerConfigSchema, } from "./schemas.js";
|
|
8
|
+
import { resolveBoardTheme } from "./theme.js";
|
|
8
9
|
import { applyTriggerToggles, effectiveTriggers, knownTrigger, REVIEW_REQUESTS_TRIGGER, reviewMaxNew, reviewMaxQueued, reviewRequestLabel, } from "./triggers.js";
|
|
9
10
|
const DEFAULTS = {
|
|
10
11
|
deliver: "local",
|
|
@@ -78,6 +79,7 @@ function project(path, revision, file) {
|
|
|
78
79
|
...(file.dailyDollarCap !== undefined ? { dailyDollarCap: file.dailyDollarCap } : {}),
|
|
79
80
|
...(sandbox ? { sandbox } : {}),
|
|
80
81
|
triggers: effectiveTriggers(file.triggers).map(triggerView),
|
|
82
|
+
board: { theme: resolveBoardTheme(file.board?.theme) },
|
|
81
83
|
defaults: DEFAULTS,
|
|
82
84
|
};
|
|
83
85
|
}
|
|
@@ -126,6 +128,11 @@ function mergeSettings(raw, settings) {
|
|
|
126
128
|
else
|
|
127
129
|
next.sandbox = prior;
|
|
128
130
|
}
|
|
131
|
+
if (settings.board) {
|
|
132
|
+
const prior = isRecord(raw.board) ? { ...raw.board } : {};
|
|
133
|
+
prior.theme = settings.board.theme;
|
|
134
|
+
next.board = prior;
|
|
135
|
+
}
|
|
129
136
|
return next;
|
|
130
137
|
}
|
|
131
138
|
function triggerView(trigger) {
|