@lchase/kiln 0.14.0 → 0.16.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/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.
@@ -6,6 +6,7 @@ import { COLUMN_LABEL, PIPELINE_COLUMNS, formatCost, formatDuration, formatLocal
6
6
  import { renderMarkdown } from "./markdown.js";
7
7
  import { kilnVersion } from "./project.js";
8
8
  import { formatSpend } from "./rails.js";
9
+ import { BOARD_THEME_CHOICES, DEFAULT_BOARD_THEME } from "./theme.js";
9
10
  import { listWorkflowNames } from "./workflow.js";
10
11
  const LIVE_SCRIPT = readFileSync(join(packageRoot(), "assets", "serve.js"), "utf8");
11
12
  export function renderConsole(css, attention, page) {
@@ -23,7 +24,7 @@ export function renderApp(model) {
23
24
  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
25
  const body = `<div class="shell">
25
26
  <aside class="nav" aria-label="Kiln">
26
- <div class="brand"><span class="logo" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="#fff" 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>
27
+ <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>
27
28
  <div class="runstate" id="runstate">
28
29
  <span class="live-dot${paused ? " paused" : ""}" id="run-dot" aria-hidden="true"></span>
29
30
  <span id="run-label">${paused ? "Paused" : "Running"}</span>
@@ -50,31 +51,34 @@ ${intakeSheet(page, model.attention)}
50
51
  revision: model.attention.revision,
51
52
  csrf: page.csrf,
52
53
  section: model.section,
54
+ theme: page.theme ?? DEFAULT_BOARD_THEME,
53
55
  ...(job ? { jobId: job.jobId, latest: job.latestEventAt } : {}),
54
56
  live: true,
55
57
  body,
56
58
  });
57
59
  }
58
- export function renderTextPage(css, title, text) {
60
+ export function renderTextPage(css, title, text, theme = DEFAULT_BOARD_THEME) {
59
61
  return document({
60
62
  css,
61
63
  title,
62
64
  needsYou: 0,
63
65
  revision: "",
64
66
  section: "job",
67
+ theme,
65
68
  live: false,
66
69
  body: `<a class="back" href="/">Pipeline</a>
67
70
  <div class="page"><h1>${escapeHtml(title)}</h1>
68
71
  <pre class="text">${escapeHtml(text)}</pre></div>`,
69
72
  });
70
73
  }
71
- export function renderMessagePage(css, title, message) {
74
+ export function renderMessagePage(css, title, message, theme = DEFAULT_BOARD_THEME) {
72
75
  return document({
73
76
  css,
74
77
  title,
75
78
  needsYou: 0,
76
79
  revision: "",
77
80
  section: "job",
81
+ theme,
78
82
  live: false,
79
83
  body: `<a class="back" href="/">Pipeline</a>
80
84
  <div class="page"><h1>${escapeHtml(title)}</h1>
@@ -112,7 +116,7 @@ ${item("/settings", "open-settings", "Settings", "settings")}
112
116
  function mainHtml(model, page) {
113
117
  const error = `<p class="meta config-error" id="config-error" role="status"${page.configError ? "" : " hidden"}>${escapeHtml(page.configError ?? "")}</p>`;
114
118
  if (model.section === "settings") {
115
- return `${error}<main class="main"><h2>Settings</h2><section id="settings" aria-label="Settings">${settingsForm()}</section></main>`;
119
+ return `${error}<main class="main"><h2>Settings</h2><section id="settings" aria-label="Settings">${settingsForm(page.theme ?? DEFAULT_BOARD_THEME)}</section></main>`;
116
120
  }
117
121
  if (model.section === "stats" && model.stats) {
118
122
  return `${error}<main class="main">${statsHtml(model.stats)}</main>`;
@@ -263,7 +267,7 @@ function jobRow(job, section, csrf) {
263
267
  classes.push("retrying");
264
268
  if (section === "inbox" || (section === "hand" && !job.scheduledRetry))
265
269
  classes.push("human");
266
- const flag = classes.includes("human") ? `<span class="flag">your call</span>` : "";
270
+ const flag = classes.includes("human") ? `<span class="flag">Needs a decision</span>` : "";
267
271
  const repo = section === "repos" ? "" : `<span class="meta">${escapeHtml(job.repo)}</span>`;
268
272
  const sandbox = job.sandboxed ? `<span class="meta">sandbox · docker</span>` : "";
269
273
  const pr = section === "repos" && job.state === "in-review" ? `<span class="meta">PR review</span>` : "";
@@ -651,6 +655,8 @@ function reportHtml(report) {
651
655
  </div>
652
656
  <section class="stat"><h3>What ran</h3>${runs}</section>`;
653
657
  }
658
+ /** Chips wrap up to this many choices. Past it, use a select or combobox. */
659
+ const PICK_CHIP_MAX = 5;
654
660
  function tableRows(rows, empty) {
655
661
  if (rows.length === 0)
656
662
  return `<p class="empty">${escapeHtml(empty)}</p>`;
@@ -668,31 +674,50 @@ function intakeSheet(intake, attention) {
668
674
  <button type="button" class="close" data-close aria-label="Close">×</button>
669
675
  </div>
670
676
  <form id="add-form" class="intake-form">
671
- <div class="field"><span class="k">Repo</span><div class="picks" id="repo-pick">${repoButtons(intake.repos)}</div></div>
672
- <label class="field"><span class="k">Workflow</span><select id="workflow" name="workflow">${workflowOptions(intake.workflows)}</select></label>
673
- <div class="seg" role="tablist" aria-label="Source">
677
+ <div class="intake-body">
678
+ <div class="field" id="repo-field"><span class="k" id="repo-label">Repo</span>${repoPicker(intake.repos)}
679
+ <p class="field-error" id="repo-error" role="alert" hidden></p></div>
680
+ <div class="field" id="workflow-field"><span class="k" id="workflow-label">Workflow</span>${workflowPicker(intake.workflows)}
681
+ <p class="field-error" id="workflow-error" role="alert" hidden></p></div>
682
+ <div class="field"><span class="k" id="source-label">Source</span>
683
+ <div class="seg" role="group" aria-labelledby="source-label">
674
684
  <button type="button" data-source="prompt" aria-pressed="true">Prompt</button>
675
685
  <button type="button" data-source="spec" aria-pressed="false">Spec file</button>
676
686
  <button type="button" data-source="tickets" aria-pressed="false">Tickets folder</button>
677
687
  <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></label></div>
680
- <div id="browser-field" hidden>
688
+ </div></div>
689
+ <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>
690
+ <p class="field-error" id="prompt-error" role="alert" hidden></p></label></div>
691
+ <div class="field" id="browser-field" hidden>
681
692
  <div class="k" id="browser-label">Spec file</div>
682
693
  <div class="fs" id="browser"></div>
683
694
  <p class="meta" id="picked"></p>
695
+ <p class="field-error" id="source-error" role="alert" hidden></p>
684
696
  </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"></label></div>
697
+ <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">
698
+ <p class="field-error" id="jira-error" role="alert" hidden></p></label></div>
686
699
  <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
700
  <label class="field" id="title-field"><span class="k">Title</span><input id="job-title" type="text" autocomplete="off"></label>
688
701
  <fieldset class="field" id="after-field"><legend class="k">After</legend><div id="after-list">${afterOptions(items.map((item) => item.job))}</div></fieldset>
689
702
  <pre class="text preview" id="intake-preview" hidden></pre>
690
703
  <p class="form-error" id="intake-error" role="alert" hidden></p>
691
- <div class="d-actions"><button type="button" id="preview-job" hidden>Preview</button><button type="submit">Add</button></div>
704
+ </div>
705
+ <div class="form-footer">
706
+ <button type="button" class="btn" id="preview-job" hidden>Preview</button>
707
+ <button type="button" class="btn" data-close>Cancel</button>
708
+ <button type="submit" class="btn primary" id="add-submit" disabled>Add</button>
709
+ </div>
692
710
  </form>
693
711
  </section>`;
694
712
  }
695
- function settingsForm() {
713
+ function themePicker(selected) {
714
+ const buttons = BOARD_THEME_CHOICES.map((theme) => {
715
+ const on = theme.id === selected;
716
+ 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>`;
717
+ }).join("");
718
+ return `<div class="themes" id="theme-pick" role="group" aria-label="Theme">${buttons}</div>`;
719
+ }
720
+ function settingsForm(theme) {
696
721
  return `<form id="settings-form" class="intake-form">
697
722
  <p class="meta" id="settings-path"></p>
698
723
  <p class="saved" id="settings-saved" role="status" hidden>Saved</p>
@@ -700,6 +725,7 @@ function settingsForm() {
700
725
  <div class="seg" role="tablist" aria-label="Settings sections">
701
726
  <button type="button" data-settings-tab="repos" aria-pressed="true">Repos</button>
702
727
  <button type="button" data-settings-tab="global" aria-pressed="false">Global</button>
728
+ <button type="button" data-settings-tab="appearance" aria-pressed="false">Appearance</button>
703
729
  </div>
704
730
  <div id="settings-repos">
705
731
  <div id="repo-editor" hidden>
@@ -760,29 +786,70 @@ function settingsForm() {
760
786
  <p class="hint" id="hint-cap"></p>
761
787
  <p class="field-error" data-error="dailyDollarCap" hidden></p>
762
788
  </div>
763
- <div class="d-actions"><button type="submit">Save</button></div>
789
+ <div id="settings-appearance" hidden>
790
+ <p class="hint">Color palette for this board. The choice applies immediately and is saved here.</p>
791
+ ${themePicker(theme)}
792
+ <p class="field-error" data-error="board.theme" hidden></p>
793
+ </div>
794
+ <div class="form-footer"><button type="submit" class="btn primary">Save</button></div>
764
795
  </form>`;
765
796
  }
766
- function repoButtons(repos) {
767
- if (repos.length === 0)
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("");
797
+ function repoListKey(repos) {
798
+ return repos.map((repo) => `${repo.name}\t${repo.path}`).join("\n");
772
799
  }
773
- function workflowOptions(names) {
774
- if (names.length === 0)
775
- return `<option value="">none</option>`;
776
- return names
777
- .map((name) => `<option value="${escapeHtml(name)}"${name === "feature" ? " selected" : ""}>${escapeHtml(name)}</option>`)
800
+ function repoPicker(repos) {
801
+ const list = escapeHtml(repoListKey(repos));
802
+ if (repos.length === 0) {
803
+ 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>`;
804
+ }
805
+ const selected = repos[0];
806
+ if (!selected) {
807
+ 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>`;
808
+ }
809
+ if (repos.length > PICK_CHIP_MAX) {
810
+ const options = repos
811
+ .map((repo, index) => {
812
+ const on = repo.name === selected.name;
813
+ 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>`;
814
+ })
815
+ .join("");
816
+ 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>`;
817
+ }
818
+ const buttons = repos
819
+ .map((repo, index) => {
820
+ const on = index === 0;
821
+ 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>`;
822
+ })
823
+ .join("");
824
+ 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>`;
825
+ }
826
+ function workflowPicker(names) {
827
+ const selected = names.includes("feature") ? "feature" : (names[0] ?? "");
828
+ if (names.length === 0 || names.length > PICK_CHIP_MAX) {
829
+ const options = names.length === 0
830
+ ? `<option value="">none</option>`
831
+ : names
832
+ .map((name) => `<option value="${escapeHtml(name)}"${name === selected ? " selected" : ""}>${escapeHtml(name)}</option>`)
833
+ .join("");
834
+ return `<select id="workflow" name="workflow" aria-labelledby="workflow-label">${options}</select>`;
835
+ }
836
+ const buttons = names
837
+ .map((name) => `<button type="button" data-workflow="${escapeHtml(name)}" aria-pressed="${name === selected ? "true" : "false"}">${escapeHtml(name)}</button>`)
778
838
  .join("");
839
+ return `<div class="seg" id="workflow-pick" data-mode="chips" data-workflow="${escapeHtml(selected)}" role="group" aria-labelledby="workflow-label">${buttons}</div>`;
840
+ }
841
+ function afterLabel(job) {
842
+ const title = job.title.trim();
843
+ if (!title || title === job.jobId)
844
+ return job.jobId;
845
+ return `${job.jobId} — ${title}`;
779
846
  }
780
847
  function afterOptions(jobs) {
781
848
  const open = jobs.filter((job) => job.state !== "done");
782
849
  if (open.length === 0)
783
850
  return `<p class="empty">No open jobs.</p>`;
784
851
  return open
785
- .map((job) => `<label class="after"><input type="checkbox" name="after" value="${escapeHtml(job.jobId)}"><span class="num">${escapeHtml(job.jobId)}</span><span>${escapeHtml(job.title || job.jobId)}</span></label>`)
852
+ .map((job) => `<label class="after"><input type="checkbox" name="after" value="${escapeHtml(job.jobId)}"><span>${escapeHtml(afterLabel(job))}</span></label>`)
786
853
  .join("");
787
854
  }
788
855
  function safeUrl(url) {
@@ -801,8 +868,9 @@ function safeUrl(url) {
801
868
  function document(options) {
802
869
  const job = options.jobId ? ` data-job="${escapeHtml(options.jobId)}"` : "";
803
870
  const latest = options.latest ? ` data-latest="${escapeHtml(options.latest)}"` : "";
871
+ const theme = options.theme ?? DEFAULT_BOARD_THEME;
804
872
  return `<!DOCTYPE html>
805
- <html lang="en">
873
+ <html lang="en" data-theme="${theme}">
806
874
  <head>
807
875
  <meta charset="utf-8">
808
876
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -812,7 +880,7 @@ function document(options) {
812
880
  ${options.css}
813
881
  </style>
814
882
  </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}>
883
+ <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
884
  ${options.body}
817
885
  ${options.live ? `<script>\n${LIVE_SCRIPT}\n</script>` : ""}
818
886
  </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}.`), secrets);
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) {
package/dist/theme.js ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Board color themes. Each id matches a `theme:<id>` block in `assets/serve.css`.
3
+ * Missing or unknown values resolve to `kiln`.
4
+ */
5
+ export const BOARD_THEME_IDS = [
6
+ "kiln",
7
+ "nord",
8
+ "gruvbox-dark",
9
+ "gruvbox-light",
10
+ "catppuccin-mocha",
11
+ "catppuccin-macchiato",
12
+ "catppuccin-frappe",
13
+ "catppuccin-latte",
14
+ "monospace-dark",
15
+ "monospace-light",
16
+ ];
17
+ export const DEFAULT_BOARD_THEME = "kiln";
18
+ export const BOARD_THEME_CHOICES = [
19
+ { id: "kiln", label: "Kiln" },
20
+ { id: "nord", label: "Nord" },
21
+ { id: "gruvbox-dark", label: "Gruvbox Dark" },
22
+ { id: "gruvbox-light", label: "Gruvbox Light" },
23
+ { id: "catppuccin-mocha", label: "Catppuccin Mocha" },
24
+ { id: "catppuccin-macchiato", label: "Catppuccin Macchiato" },
25
+ { id: "catppuccin-frappe", label: "Catppuccin Frappé" },
26
+ { id: "catppuccin-latte", label: "Catppuccin Latte" },
27
+ { id: "monospace-dark", label: "Monospace Dark" },
28
+ { id: "monospace-light", label: "Monospace Light" },
29
+ ];
30
+ export function resolveBoardTheme(value) {
31
+ if (typeof value === "string" && BOARD_THEME_IDS.includes(value)) {
32
+ return value;
33
+ }
34
+ return DEFAULT_BOARD_THEME;
35
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lchase/kiln",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Run a workflow on any registered repo. A markdown spec is the input.",
5
5
  "license": "MIT",
6
6
  "type": "module",