@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/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.
@@ -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 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>
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
- ${spend}${capNote}
33
- ${navHtml(model)}
34
- <button class="add" type="button" id="add-job">Add job</button>
35
- <button class="tool-link" type="button" id="notify">Notify me</button>
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)}${count}</a>`;
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">your call</span>` : "";
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="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">
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></label></div>
680
- <div id="browser-field" hidden>
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"></label></div>
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
- <div class="d-actions"><button type="button" id="preview-job" hidden>Preview</button><button type="submit">Add</button></div>
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 settingsForm() {
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 class="d-actions"><button type="submit">Save</button></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 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("");
805
+ function repoListKey(repos) {
806
+ return repos.map((repo) => `${repo.name}\t${repo.path}`).join("\n");
772
807
  }
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>`)
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 class="num">${escapeHtml(job.jobId)}</span><span>${escapeHtml(job.title || job.jobId)}</span></label>`)
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}.`), 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) {