prodboard 0.2.1 → 0.2.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # prodboard
2
2
 
3
+ ## 0.2.3
4
+
5
+ ### Patch Changes
6
+
7
+ - [#13](https://github.com/G4brym/prodboard/pull/13) [`5465723`](https://github.com/G4brym/prodboard/commit/54657238021a20fb0d5a4bb6e13a1b8eab026c31) Thanks [@G4brym](https://github.com/G4brym)! - Fix systemd service missing PATH environment variable
8
+
9
+ The generated systemd service file only set `HOME` but not `PATH`, causing the daemon to run with a minimal default PATH. This meant tools like `claude` and `gh` installed in user-local directories (e.g. `~/.local/bin`) were not found, resulting in scheduled runs failing with exit code 127.
10
+
11
+ The fix captures the current `PATH` at install time and includes it in the systemd service file.
12
+
13
+ - [`af1ff32`](https://github.com/G4brym/prodboard/commit/af1ff3291993e0720a4044220b84f8b048fef26e) Thanks [@G4brym](https://github.com/G4brym)! - Fix webui failing to load when prodboard is installed globally by adding `@jsxImportSource hono/jsx` pragma to all TSX files.
14
+
15
+ ## 0.2.2
16
+
17
+ ### Patch Changes
18
+
19
+ - [`62ef4b0`](https://github.com/G4brym/prodboard/commit/62ef4b0a1f6969e3d06f549635ebfd337b84403c) Thanks [@G4brym](https://github.com/G4brym)! - Show config warnings (tmux availability, webui dependencies) on every CLI command. Improved webui dependency messages with actionable install commands.
20
+
3
21
  ## 0.2.1
4
22
 
5
23
  ### Patch Changes
package/README.md CHANGED
@@ -166,6 +166,7 @@ prodboard schedule stats --days 7 # Statistics
166
166
  prodboard daemon # Start (foreground)
167
167
  prodboard daemon --dry-run # Preview schedules
168
168
  prodboard daemon status # Check if running
169
+ prodboard daemon restart # Restart via systemd
169
170
  ```
170
171
 
171
172
  ### Other
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prodboard",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Self-hosted, CLI-first issue tracker and cron scheduler for AI coding agents",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -2,7 +2,7 @@ import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import * as os from "os";
4
4
  import { ensureDb } from "../db.ts";
5
- import { loadConfig, loadConfigRaw, validateConfig, checkWebuiDependencies, PRODBOARD_DIR } from "../config.ts";
5
+ import { loadConfig, PRODBOARD_DIR } from "../config.ts";
6
6
  import { listSchedules } from "../queries/schedules.ts";
7
7
  import { getNextFire } from "../cron.ts";
8
8
  import { formatDate } from "../format.ts";
@@ -114,34 +114,6 @@ export async function daemonStatus(args: string[]): Promise<void> {
114
114
  }
115
115
 
116
116
  export async function daemonRestart(_args: string[]): Promise<void> {
117
- // Validate config
118
- let config;
119
- try {
120
- const { config: cfg, rawParsed } = loadConfigRaw();
121
- config = cfg;
122
- const { errors, warnings } = validateConfig(rawParsed);
123
- for (const e of errors) {
124
- console.error(`✗ Config: ${e}`);
125
- }
126
- if (errors.length > 0) {
127
- process.exit(1);
128
- }
129
- for (const w of warnings) {
130
- console.warn(`⚠ Config: ${w}`);
131
- }
132
- } catch (err: any) {
133
- console.error(`Config error: ${err.message}`);
134
- process.exit(1);
135
- }
136
-
137
- // Check webui dependencies
138
- if (config.webui.enabled) {
139
- const depWarnings = await checkWebuiDependencies();
140
- for (const w of depWarnings) {
141
- console.warn(`⚠ ${w}`);
142
- }
143
- }
144
-
145
117
  // Check systemd availability
146
118
  if (!(await systemctlAvailable())) {
147
119
  console.error("systemd is not available. daemon restart requires systemd.");
@@ -1,7 +1,7 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import * as os from "os";
4
- import { loadConfigRaw, validateConfig, checkWebuiDependencies } from "../config.ts";
4
+
5
5
 
6
6
  const SERVICE_NAME = "prodboard";
7
7
  const SERVICE_DIR = path.join(os.homedir(), ".config", "systemd", "user");
@@ -43,7 +43,8 @@ export async function runSystemctl(...args: string[]): Promise<{ exitCode: numbe
43
43
  return { exitCode, stdout, stderr };
44
44
  }
45
45
 
46
- export function generateServiceFile(bunPath: string, prodboardPath: string, home: string): string {
46
+ export function generateServiceFile(bunPath: string, prodboardPath: string, home: string, envPath?: string): string {
47
+ const pathLine = envPath ? `\nEnvironment="PATH=${envPath}"` : "";
47
48
  return `[Unit]
48
49
  Description=prodboard scheduler daemon
49
50
  After=network.target
@@ -53,7 +54,7 @@ Type=simple
53
54
  ExecStart=${bunPath} run ${prodboardPath} daemon
54
55
  Restart=on-failure
55
56
  RestartSec=10
56
- Environment="HOME=${home}"
57
+ Environment="HOME=${home}"${pathLine}
57
58
 
58
59
  [Install]
59
60
  WantedBy=default.target
@@ -63,24 +64,6 @@ WantedBy=default.target
63
64
  export async function install(args: string[]): Promise<void> {
64
65
  const { flags } = parseArgs(args);
65
66
 
66
- // Validate config before proceeding
67
- try {
68
- const { config, rawParsed } = loadConfigRaw();
69
- const { warnings } = validateConfig(rawParsed);
70
- for (const w of warnings) {
71
- console.warn(`⚠ Config: ${w}`);
72
- }
73
- if (config.webui.enabled) {
74
- const depWarnings = await checkWebuiDependencies();
75
- for (const w of depWarnings) {
76
- console.warn(`⚠ ${w}`);
77
- }
78
- }
79
- } catch (err: any) {
80
- console.error(`Config error: ${err.message}`);
81
- process.exit(1);
82
- }
83
-
84
67
  if (!(await systemctlAvailable())) {
85
68
  console.error("systemd is not available on this system.");
86
69
  console.error("The install command requires systemd (Linux).");
@@ -101,7 +84,7 @@ export async function install(args: string[]): Promise<void> {
101
84
  const prodboardPath = Bun.which("prodboard") ?? `${bunPath} x prodboard`;
102
85
  const home = os.homedir();
103
86
 
104
- const serviceContent = generateServiceFile(bunPath, prodboardPath, home);
87
+ const serviceContent = generateServiceFile(bunPath, prodboardPath, home, process.env.PATH);
105
88
 
106
89
  fs.mkdirSync(SERVICE_DIR, { recursive: true });
107
90
  fs.writeFileSync(SERVICE_PATH, serviceContent);
package/src/config.ts CHANGED
@@ -240,15 +240,64 @@ export async function checkWebuiDependencies(): Promise<string[]> {
240
240
  try {
241
241
  await import("hono");
242
242
  } catch {
243
- warnings.push("webui is enabled but 'hono' is not installed. Run: bun install");
243
+ warnings.push(
244
+ "webui is enabled but 'hono' is not installed. " +
245
+ "Run: bun install hono (or bun install -g hono if prodboard is installed globally)"
246
+ );
247
+ return warnings; // skip JSX check if hono itself is missing
244
248
  }
245
249
  try {
246
250
  await import("hono/jsx/jsx-runtime");
247
251
  } catch {
248
252
  warnings.push(
249
253
  "webui is enabled but the Hono JSX runtime could not be loaded. " +
250
- "If prodboard is installed globally, you may need to install hono in the global package directory."
254
+ "Run: bun install hono (or bun install -g hono if prodboard is installed globally)"
251
255
  );
252
256
  }
253
257
  return warnings;
254
258
  }
259
+
260
+ export async function checkTmuxAvailable(): Promise<string | null> {
261
+ try {
262
+ const proc = Bun.spawn(["tmux", "-V"], { stdout: "ignore", stderr: "ignore" });
263
+ const code = await proc.exited;
264
+ if (code !== 0) {
265
+ return "daemon.useTmux is enabled but tmux is not installed. Install it (e.g. apt install tmux) or set useTmux to false.";
266
+ }
267
+ return null;
268
+ } catch {
269
+ return "daemon.useTmux is enabled but tmux is not installed. Install it (e.g. apt install tmux) or set useTmux to false.";
270
+ }
271
+ }
272
+
273
+ export async function printConfigWarnings(): Promise<void> {
274
+ let config: Config;
275
+ let rawParsed: any;
276
+ try {
277
+ const result = loadConfigRaw();
278
+ config = result.config;
279
+ rawParsed = result.rawParsed;
280
+ } catch (err: any) {
281
+ console.warn(`⚠ Config: ${err.message}`);
282
+ return;
283
+ }
284
+
285
+ const { warnings } = validateConfig(rawParsed);
286
+ for (const w of warnings) {
287
+ console.warn(`⚠ Config: ${w}`);
288
+ }
289
+
290
+ if (config.daemon.useTmux) {
291
+ const tmuxWarning = await checkTmuxAvailable();
292
+ if (tmuxWarning) {
293
+ console.warn(`⚠ ${tmuxWarning}`);
294
+ }
295
+ }
296
+
297
+ if (config.webui.enabled) {
298
+ const depWarnings = await checkWebuiDependencies();
299
+ for (const w of depWarnings) {
300
+ console.warn(`⚠ ${w}`);
301
+ }
302
+ }
303
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from "fs";
2
- import { PRODBOARD_DIR } from "./config.ts";
2
+ import { PRODBOARD_DIR, printConfigWarnings } from "./config.ts";
3
3
 
4
4
  export class NotInitializedError extends Error {
5
5
  constructor() {
@@ -36,6 +36,17 @@ export async function main(): Promise<void> {
36
36
  return;
37
37
  }
38
38
 
39
+ // Show config warnings for commands that use the config
40
+ const skipWarnings = ["init", "mcp", "version", "--version", "help", "--help", "uninstall"];
41
+ if (!skipWarnings.includes(command)) {
42
+ try {
43
+ ensureInitialized();
44
+ await printConfigWarnings();
45
+ } catch {
46
+ // ensureInitialized will throw again inside the switch if needed
47
+ }
48
+ }
49
+
39
50
  try {
40
51
  switch (command) {
41
52
  case "init": {
@@ -1,3 +1,4 @@
1
+ /** @jsxImportSource hono/jsx */
1
2
  import type { FC } from "hono/jsx";
2
3
  import type { Issue } from "../../types.ts";
3
4
  import { StatusBadge } from "./status-badge.tsx";
@@ -13,18 +14,25 @@ export const Board: FC<{ issues: Issue[]; statuses: string[] }> = ({ issues, sta
13
14
  }
14
15
 
15
16
  return (
16
- <div class="board">
17
+ <div class="flex gap-4 overflow-x-auto pb-4">
17
18
  {statuses.filter((s) => s !== "archived").map((status) => (
18
- <div class="board-column" key={status}>
19
- <h3>
20
- {status} <span class="count">({(grouped[status] || []).length})</span>
21
- </h3>
22
- {(grouped[status] || []).map((issue) => (
23
- <a href={`/issues/${issue.id}`} class="card" key={issue.id}>
24
- <div class="card-title">{issue.title}</div>
25
- <div class="card-meta">{issue.id.slice(0, 8)}</div>
26
- </a>
27
- ))}
19
+ <div class="flex-1 min-w-[220px]" key={status}>
20
+ <div class="flex items-center gap-2 mb-3 px-1">
21
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{status}</h3>
22
+ <span class="text-xs text-muted-foreground/60">{(grouped[status] || []).length}</span>
23
+ </div>
24
+ <div class="space-y-2">
25
+ {(grouped[status] || []).map((issue) => (
26
+ <a
27
+ href={`/issues/${issue.id}`}
28
+ class="block rounded-lg border border-border bg-card p-3 hover:bg-accent transition-colors group"
29
+ key={issue.id}
30
+ >
31
+ <div class="text-sm font-medium text-card-foreground group-hover:text-foreground">{issue.title}</div>
32
+ <div class="text-xs text-muted-foreground mt-1 font-mono">{issue.id.slice(0, 8)}</div>
33
+ </a>
34
+ ))}
35
+ </div>
28
36
  </div>
29
37
  ))}
30
38
  </div>
@@ -1,5 +1,7 @@
1
+ /** @jsxImportSource hono/jsx */
1
2
  import type { FC } from "hono/jsx";
2
- import { STYLES } from "../static/style.ts";
3
+ import { raw } from "hono/html";
4
+ import { TAILWIND_SCRIPT, THEME, STYLES } from "../static/style.ts";
3
5
 
4
6
  export const Layout: FC<{ title?: string; children: any }> = ({ title, children }) => {
5
7
  return (
@@ -8,20 +10,30 @@ export const Layout: FC<{ title?: string; children: any }> = ({ title, children
8
10
  <meta charset="utf-8" />
9
11
  <meta name="viewport" content="width=device-width, initial-scale=1" />
10
12
  <title>{title ? `${title} - prodboard` : "prodboard"}</title>
13
+ {raw(TAILWIND_SCRIPT)}
14
+ {raw(THEME)}
11
15
  <style>{STYLES}</style>
12
16
  </head>
13
- <body>
14
- <nav>
15
- <div class="nav-inner">
16
- <a href="/" class="logo">prodboard</a>
17
- <div class="nav-links">
18
- <a href="/issues">Issues</a>
19
- <a href="/schedules">Schedules</a>
20
- <a href="/runs">Runs</a>
21
- </div>
17
+ <body class="bg-background text-foreground min-h-screen">
18
+ <header class="border-b border-border">
19
+ <div class="mx-auto max-w-7xl flex items-center justify-between px-6 h-14">
20
+ <a href="/" class="text-sm font-bold tracking-tight text-foreground hover:text-foreground/80">
21
+ prodboard
22
+ </a>
23
+ <nav class="flex items-center gap-1">
24
+ <a href="/issues" class="px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-md transition-colors">
25
+ Issues
26
+ </a>
27
+ <a href="/schedules" class="px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-md transition-colors">
28
+ Schedules
29
+ </a>
30
+ <a href="/runs" class="px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-md transition-colors">
31
+ Runs
32
+ </a>
33
+ </nav>
22
34
  </div>
23
- </nav>
24
- <main>{children}</main>
35
+ </header>
36
+ <main class="mx-auto max-w-7xl px-6 py-6">{children}</main>
25
37
  </body>
26
38
  </html>
27
39
  );
@@ -1,22 +1,23 @@
1
+ /** @jsxImportSource hono/jsx */
1
2
  import type { FC } from "hono/jsx";
2
3
 
3
- const STATUS_COLORS: Record<string, string> = {
4
- "todo": "#6b7280",
5
- "in-progress": "#3b82f6",
6
- "review": "#f59e0b",
7
- "done": "#10b981",
8
- "archived": "#9ca3af",
9
- "running": "#3b82f6",
10
- "success": "#10b981",
11
- "failed": "#ef4444",
12
- "timeout": "#f59e0b",
13
- "cancelled": "#9ca3af",
4
+ const STATUS_STYLES: Record<string, string> = {
5
+ "todo": "bg-zinc-700/50 text-zinc-300 border-zinc-600",
6
+ "in-progress": "bg-blue-500/15 text-blue-400 border-blue-500/30",
7
+ "review": "bg-amber-500/15 text-amber-400 border-amber-500/30",
8
+ "done": "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
9
+ "archived": "bg-zinc-700/30 text-zinc-500 border-zinc-600/50",
10
+ "running": "bg-blue-500/15 text-blue-400 border-blue-500/30",
11
+ "success": "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
12
+ "failed": "bg-red-500/15 text-red-400 border-red-500/30",
13
+ "timeout": "bg-amber-500/15 text-amber-400 border-amber-500/30",
14
+ "cancelled": "bg-zinc-700/30 text-zinc-500 border-zinc-600/50",
14
15
  };
15
16
 
16
17
  export const StatusBadge: FC<{ status: string }> = ({ status }) => {
17
- const color = STATUS_COLORS[status] ?? "#6b7280";
18
+ const styles = STATUS_STYLES[status] ?? "bg-zinc-700/50 text-zinc-300 border-zinc-600";
18
19
  return (
19
- <span class="badge" style={`background:${color}`}>
20
+ <span class={`inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-md border ${styles}`}>
20
21
  {status}
21
22
  </span>
22
23
  );
@@ -1,3 +1,4 @@
1
+ /** @jsxImportSource hono/jsx */
1
2
  import { Hono } from "hono";
2
3
  import crypto from "crypto";
3
4
  import { Layout } from "../components/layout.tsx";
@@ -22,16 +23,39 @@ export function authRoutes(_db: Database, _config: Config, authSalt: string) {
22
23
  const error = c.req.query("error");
23
24
  return c.html(
24
25
  <Layout title="Login">
25
- <div class="login-box">
26
- <h1>Login</h1>
27
- {error && <div class="flash">Invalid password</div>}
28
- <form method="post" action="/login">
29
- <div class="form-row">
30
- <label for="password">Password</label>
31
- <input type="password" name="password" id="password" required autofocus />
26
+ <div class="flex items-center justify-center min-h-[calc(100vh-10rem)]">
27
+ <div class="w-full max-w-sm">
28
+ <div class="rounded-lg border border-border bg-card p-6">
29
+ <div class="mb-6">
30
+ <h1 class="text-lg font-semibold text-card-foreground">Login</h1>
31
+ <p class="text-sm text-muted-foreground mt-1">Enter your password to access prodboard.</p>
32
+ </div>
33
+ {error && (
34
+ <div class="mb-4 rounded-md border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-400">
35
+ Invalid password
36
+ </div>
37
+ )}
38
+ <form method="post" action="/login">
39
+ <div class="mb-4">
40
+ <label for="password" class="block text-sm font-medium text-foreground mb-1.5">Password</label>
41
+ <input
42
+ type="password"
43
+ name="password"
44
+ id="password"
45
+ required
46
+ autofocus
47
+ class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background"
48
+ />
49
+ </div>
50
+ <button
51
+ type="submit"
52
+ class="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
53
+ >
54
+ Sign in
55
+ </button>
56
+ </form>
32
57
  </div>
33
- <button type="submit" class="btn btn-primary">Login</button>
34
- </form>
58
+ </div>
35
59
  </div>
36
60
  </Layout>
37
61
  );
@@ -1,3 +1,4 @@
1
+ /** @jsxImportSource hono/jsx */
1
2
  import { Hono } from "hono";
2
3
  import type { Database } from "bun:sqlite";
3
4
  import type { Config } from "../../types.ts";
@@ -17,32 +18,63 @@ export function issueRoutes(db: Database, config: Config) {
17
18
  const { issues } = listIssues(db, { includeArchived: true, limit: 500 });
18
19
  return c.html(
19
20
  <Layout title="Issues">
20
- <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
21
- <h1>Issues</h1>
22
- <a href="#new-issue" class="btn btn-primary btn-sm" onclick="document.getElementById('new-issue-form').style.display='block'">New Issue</a>
21
+ <div class="flex items-center justify-between mb-6">
22
+ <div>
23
+ <h1 class="text-xl font-semibold">Issues</h1>
24
+ <p class="text-sm text-muted-foreground mt-0.5">{issues.length} total</p>
25
+ </div>
26
+ <button
27
+ onclick="document.getElementById('new-issue-form').classList.toggle('hidden')"
28
+ class="inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
29
+ >
30
+ <svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /></svg>
31
+ New Issue
32
+ </button>
23
33
  </div>
24
- <div id="new-issue-form" style="display:none;margin-bottom:1rem" class="detail">
25
- <h2>New Issue</h2>
26
- <form method="post" action="/issues">
27
- <div class="form-row">
28
- <label for="title">Title</label>
29
- <input type="text" name="title" id="title" required />
30
- </div>
31
- <div class="form-row">
32
- <label for="description">Description</label>
33
- <textarea name="description" id="description"></textarea>
34
- </div>
35
- <div class="form-row">
36
- <label for="status">Status</label>
37
- <select name="status" id="status">
38
- {config.general.statuses.map((s) => (
39
- <option value={s} selected={s === config.general.defaultStatus}>{s}</option>
40
- ))}
41
- </select>
42
- </div>
43
- <button type="submit" class="btn btn-primary">Create</button>
44
- </form>
34
+
35
+ <div id="new-issue-form" class="hidden mb-6">
36
+ <div class="rounded-lg border border-border bg-card p-5">
37
+ <h2 class="text-base font-semibold text-card-foreground mb-4">Create Issue</h2>
38
+ <form method="post" action="/issues">
39
+ <div class="grid gap-4">
40
+ <div>
41
+ <label for="title" class="block text-sm font-medium text-foreground mb-1.5">Title</label>
42
+ <input type="text" name="title" id="title" required
43
+ class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background"
44
+ placeholder="Issue title"
45
+ />
46
+ </div>
47
+ <div>
48
+ <label for="description" class="block text-sm font-medium text-foreground mb-1.5">Description</label>
49
+ <textarea name="description" id="description" rows={3}
50
+ class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background resize-y"
51
+ placeholder="Optional description"
52
+ ></textarea>
53
+ </div>
54
+ <div>
55
+ <label for="status" class="block text-sm font-medium text-foreground mb-1.5">Status</label>
56
+ <select name="status" id="status"
57
+ class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background"
58
+ >
59
+ {config.general.statuses.map((s) => (
60
+ <option value={s} selected={s === config.general.defaultStatus}>{s}</option>
61
+ ))}
62
+ </select>
63
+ </div>
64
+ <div class="flex justify-end gap-2">
65
+ <button type="button"
66
+ onclick="document.getElementById('new-issue-form').classList.add('hidden')"
67
+ class="rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-accent transition-colors"
68
+ >Cancel</button>
69
+ <button type="submit"
70
+ class="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
71
+ >Create</button>
72
+ </div>
73
+ </div>
74
+ </form>
75
+ </div>
45
76
  </div>
77
+
46
78
  <Board issues={issues} statuses={config.general.statuses} />
47
79
  </Layout>
48
80
  );
@@ -68,46 +100,73 @@ export function issueRoutes(db: Database, config: Config) {
68
100
  const comments = listComments(db, issue.id);
69
101
  return c.html(
70
102
  <Layout title={issue.title}>
71
- <div class="detail">
72
- <h1>
73
- <StatusBadge status={issue.status} />
74
- {issue.title}
75
- </h1>
76
- <div class="detail-meta">
77
- ID: {issue.id} | Created: {issue.created_at} | Updated: {issue.updated_at}
103
+ <div class="mb-4">
104
+ <a href="/issues" class="text-sm text-muted-foreground hover:text-foreground transition-colors">&larr; Back to issues</a>
105
+ </div>
106
+
107
+ <div class="rounded-lg border border-border bg-card p-5 mb-4">
108
+ <div class="flex items-start justify-between gap-4">
109
+ <div class="flex items-center gap-3">
110
+ <StatusBadge status={issue.status} />
111
+ <h1 class="text-lg font-semibold text-card-foreground">{issue.title}</h1>
112
+ </div>
78
113
  </div>
79
- {issue.description && <div class="description">{issue.description}</div>}
80
- <div class="actions">
81
- <form method="post" action={`/issues/${issue.id}/move`} style="display:flex;gap:0.5rem;align-items:center">
82
- <select name="status">
114
+ <div class="mt-2 flex items-center gap-3 text-xs text-muted-foreground font-mono">
115
+ <span>{issue.id}</span>
116
+ <span>&middot;</span>
117
+ <span>Created {issue.created_at}</span>
118
+ <span>&middot;</span>
119
+ <span>Updated {issue.updated_at}</span>
120
+ </div>
121
+ {issue.description && (
122
+ <div class="mt-4 rounded-md bg-muted p-4 text-sm text-foreground whitespace-pre-wrap">{issue.description}</div>
123
+ )}
124
+ <div class="mt-4 flex items-center gap-2 pt-4 border-t border-border">
125
+ <form method="post" action={`/issues/${issue.id}/move`} class="flex items-center gap-2">
126
+ <select name="status"
127
+ class="rounded-md border border-input bg-background px-2.5 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
128
+ >
83
129
  {config.general.statuses.map((s) => (
84
130
  <option value={s} selected={s === issue.status}>{s}</option>
85
131
  ))}
86
132
  </select>
87
- <button type="submit" class="btn btn-primary btn-sm">Move</button>
133
+ <button type="submit"
134
+ class="rounded-md bg-secondary px-2.5 py-1.5 text-sm font-medium text-secondary-foreground hover:bg-secondary/80 transition-colors"
135
+ >Move</button>
88
136
  </form>
89
137
  <form method="post" action={`/issues/${issue.id}/delete`} onsubmit="return confirm('Delete this issue?')">
90
- <button type="submit" class="btn btn-danger btn-sm">Delete</button>
138
+ <button type="submit"
139
+ class="rounded-md bg-destructive/15 border border-destructive/30 px-2.5 py-1.5 text-sm font-medium text-red-400 hover:bg-destructive/25 transition-colors"
140
+ >Delete</button>
91
141
  </form>
92
142
  </div>
93
143
  </div>
94
144
 
95
- <div class="detail">
96
- <h2>Comments ({comments.length})</h2>
97
- {comments.map((comment) => (
98
- <div class="comment" key={comment.id}>
99
- <div>
100
- <span class="comment-author">{comment.author}</span>
101
- <span class="comment-date"> - {comment.created_at}</span>
102
- </div>
103
- <div>{comment.body}</div>
145
+ <div class="rounded-lg border border-border bg-card p-5">
146
+ <h2 class="text-sm font-semibold text-card-foreground mb-4">Comments <span class="text-muted-foreground font-normal">({comments.length})</span></h2>
147
+ {comments.length > 0 && (
148
+ <div class="space-y-3 mb-4">
149
+ {comments.map((comment) => (
150
+ <div class="border-l-2 border-border pl-3 py-1" key={comment.id}>
151
+ <div class="flex items-center gap-2 text-xs">
152
+ <span class="font-semibold text-foreground">{comment.author}</span>
153
+ <span class="text-muted-foreground">{comment.created_at}</span>
154
+ </div>
155
+ <div class="text-sm text-foreground/90 mt-0.5">{comment.body}</div>
156
+ </div>
157
+ ))}
104
158
  </div>
105
- ))}
106
- <form method="post" action={`/issues/${issue.id}/comment`} style="margin-top:1rem">
107
- <div class="form-row">
108
- <textarea name="body" placeholder="Add a comment..." required></textarea>
159
+ )}
160
+ <form method="post" action={`/issues/${issue.id}/comment`}>
161
+ <textarea name="body" required rows={2}
162
+ class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background resize-y"
163
+ placeholder="Add a comment..."
164
+ ></textarea>
165
+ <div class="flex justify-end mt-2">
166
+ <button type="submit"
167
+ class="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
168
+ >Comment</button>
109
169
  </div>
110
- <button type="submit" class="btn btn-primary btn-sm">Comment</button>
111
170
  </form>
112
171
  </div>
113
172
  </Layout>
@@ -1,3 +1,4 @@
1
+ /** @jsxImportSource hono/jsx */
1
2
  import { Hono } from "hono";
2
3
  import type { Database } from "bun:sqlite";
3
4
  import type { Config } from "../../types.ts";
@@ -13,31 +14,46 @@ export function runRoutes(db: Database, _config: Config) {
13
14
  const runs = listRuns(db, { limit: 50 });
14
15
  return c.html(
15
16
  <Layout title="Runs">
16
- <h1>Runs</h1>
17
- <table>
18
- <thead>
19
- <tr>
20
- <th>ID</th>
21
- <th>Schedule</th>
22
- <th>Agent</th>
23
- <th>Status</th>
24
- <th>Started</th>
25
- <th>Cost</th>
26
- </tr>
27
- </thead>
28
- <tbody>
29
- {runs.map((run) => (
30
- <tr key={run.id}>
31
- <td><a href={`/runs/${run.id}`}>{run.id.slice(0, 8)}</a></td>
32
- <td>{run.schedule_name ?? run.schedule_id.slice(0, 8)}</td>
33
- <td>{run.agent}</td>
34
- <td><StatusBadge status={run.status} /></td>
35
- <td>{run.started_at}</td>
36
- <td>{run.cost_usd != null ? `$${run.cost_usd.toFixed(4)}` : "-"}</td>
17
+ <div class="mb-6">
18
+ <h1 class="text-xl font-semibold">Runs</h1>
19
+ <p class="text-sm text-muted-foreground mt-0.5">Recent execution history</p>
20
+ </div>
21
+
22
+ <div class="rounded-lg border border-border overflow-hidden">
23
+ <table class="w-full">
24
+ <thead>
25
+ <tr class="border-b border-border bg-muted/50">
26
+ <th class="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">ID</th>
27
+ <th class="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">Schedule</th>
28
+ <th class="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">Agent</th>
29
+ <th class="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">Status</th>
30
+ <th class="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">Started</th>
31
+ <th class="text-right px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">Cost</th>
37
32
  </tr>
38
- ))}
39
- </tbody>
40
- </table>
33
+ </thead>
34
+ <tbody class="divide-y divide-border">
35
+ {runs.map((run) => (
36
+ <tr class="hover:bg-muted/30 transition-colors" key={run.id}>
37
+ <td class="px-4 py-3">
38
+ <a href={`/runs/${run.id}`} class="text-sm font-mono text-blue-400 hover:text-blue-300 hover:underline">
39
+ {run.id.slice(0, 8)}
40
+ </a>
41
+ </td>
42
+ <td class="px-4 py-3 text-sm text-foreground">{run.schedule_name ?? run.schedule_id.slice(0, 8)}</td>
43
+ <td class="px-4 py-3 text-sm text-muted-foreground">{run.agent}</td>
44
+ <td class="px-4 py-3"><StatusBadge status={run.status} /></td>
45
+ <td class="px-4 py-3 text-sm text-muted-foreground">{run.started_at}</td>
46
+ <td class="px-4 py-3 text-sm text-right font-mono text-muted-foreground">
47
+ {run.cost_usd != null ? `$${run.cost_usd.toFixed(4)}` : "-"}
48
+ </td>
49
+ </tr>
50
+ ))}
51
+ </tbody>
52
+ </table>
53
+ {runs.length === 0 && (
54
+ <div class="px-4 py-8 text-center text-sm text-muted-foreground">No runs yet.</div>
55
+ )}
56
+ </div>
41
57
  </Layout>
42
58
  );
43
59
  });
@@ -48,38 +64,60 @@ export function runRoutes(db: Database, _config: Config) {
48
64
  const run = runs.find((r) => r.id === id || r.id.startsWith(id));
49
65
  if (!run) return c.text("Run not found", 404);
50
66
 
67
+ const details = [
68
+ { label: "Exit Code", value: run.exit_code ?? "-" },
69
+ { label: "Tokens In", value: run.tokens_in != null ? run.tokens_in.toLocaleString() : "-" },
70
+ { label: "Tokens Out", value: run.tokens_out != null ? run.tokens_out.toLocaleString() : "-" },
71
+ { label: "Cost", value: run.cost_usd != null ? `$${run.cost_usd.toFixed(4)}` : "-" },
72
+ { label: "Session ID", value: run.session_id ?? "-" },
73
+ { label: "tmux Session", value: run.tmux_session ?? "-" },
74
+ { label: "Tools Used", value: run.tools_used ?? "-" },
75
+ { label: "Issues Touched", value: run.issues_touched ?? "-" },
76
+ ];
77
+
51
78
  return c.html(
52
79
  <Layout title={`Run ${run.id.slice(0, 8)}`}>
53
- <div class="detail">
54
- <h1>
80
+ <div class="mb-4">
81
+ <a href="/runs" class="text-sm text-muted-foreground hover:text-foreground transition-colors">&larr; Back to runs</a>
82
+ </div>
83
+
84
+ <div class="rounded-lg border border-border bg-card p-5 mb-4">
85
+ <div class="flex items-center gap-3">
55
86
  <StatusBadge status={run.status} />
56
- Run {run.id.slice(0, 8)}
57
- </h1>
58
- <div class="detail-meta">
59
- Schedule: {run.schedule_name ?? run.schedule_id.slice(0, 8)} |
60
- Agent: {run.agent} |
61
- Started: {run.started_at}
62
- {run.finished_at && ` | Finished: ${run.finished_at}`}
87
+ <h1 class="text-lg font-semibold text-card-foreground font-mono">Run {run.id.slice(0, 8)}</h1>
88
+ </div>
89
+ <div class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
90
+ <span>Schedule: {run.schedule_name ?? run.schedule_id.slice(0, 8)}</span>
91
+ <span>&middot;</span>
92
+ <span>Agent: {run.agent}</span>
93
+ <span>&middot;</span>
94
+ <span>Started: {run.started_at}</span>
95
+ {run.finished_at && (
96
+ <>
97
+ <span>&middot;</span>
98
+ <span>Finished: {run.finished_at}</span>
99
+ </>
100
+ )}
101
+ </div>
102
+ </div>
103
+
104
+ <div class="rounded-lg border border-border bg-card overflow-hidden mb-4">
105
+ <div class="divide-y divide-border">
106
+ {details.map((d) => (
107
+ <div class="flex items-center px-4 py-2.5 hover:bg-muted/30 transition-colors" key={d.label}>
108
+ <span class="text-sm font-medium text-muted-foreground w-40 shrink-0">{d.label}</span>
109
+ <span class="text-sm text-foreground font-mono">{d.value}</span>
110
+ </div>
111
+ ))}
63
112
  </div>
64
- <table>
65
- <tbody>
66
- <tr><td><strong>Exit Code</strong></td><td>{run.exit_code ?? "-"}</td></tr>
67
- <tr><td><strong>Tokens In</strong></td><td>{run.tokens_in ?? "-"}</td></tr>
68
- <tr><td><strong>Tokens Out</strong></td><td>{run.tokens_out ?? "-"}</td></tr>
69
- <tr><td><strong>Cost</strong></td><td>{run.cost_usd != null ? `$${run.cost_usd.toFixed(4)}` : "-"}</td></tr>
70
- <tr><td><strong>Session ID</strong></td><td>{run.session_id ?? "-"}</td></tr>
71
- <tr><td><strong>tmux Session</strong></td><td>{run.tmux_session ?? "-"}</td></tr>
72
- <tr><td><strong>Tools Used</strong></td><td>{run.tools_used ?? "-"}</td></tr>
73
- <tr><td><strong>Issues Touched</strong></td><td>{run.issues_touched ?? "-"}</td></tr>
74
- </tbody>
75
- </table>
76
- {run.stderr_tail && (
77
- <div>
78
- <h3 style="margin-top:1rem">Stderr</h3>
79
- <pre class="description">{run.stderr_tail}</pre>
80
- </div>
81
- )}
82
113
  </div>
114
+
115
+ {run.stderr_tail && (
116
+ <div class="rounded-lg border border-border bg-card p-5">
117
+ <h3 class="text-sm font-semibold text-card-foreground mb-3">Stderr</h3>
118
+ <pre class="rounded-md bg-muted p-4 text-sm text-foreground overflow-x-auto font-mono whitespace-pre-wrap">{run.stderr_tail}</pre>
119
+ </div>
120
+ )}
83
121
  </Layout>
84
122
  );
85
123
  });
@@ -1,3 +1,4 @@
1
+ /** @jsxImportSource hono/jsx */
1
2
  import { Hono } from "hono";
2
3
  import type { Database } from "bun:sqlite";
3
4
  import type { Config } from "../../types.ts";
@@ -17,69 +18,119 @@ export function scheduleRoutes(db: Database, _config: Config) {
17
18
  const schedules = listSchedules(db, { includeDisabled: true });
18
19
  return c.html(
19
20
  <Layout title="Schedules">
20
- <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
21
- <h1>Schedules</h1>
22
- <a href="#" class="btn btn-primary btn-sm" onclick="document.getElementById('new-schedule-form').style.display='block'">New Schedule</a>
21
+ <div class="flex items-center justify-between mb-6">
22
+ <div>
23
+ <h1 class="text-xl font-semibold">Schedules</h1>
24
+ <p class="text-sm text-muted-foreground mt-0.5">{schedules.length} total</p>
25
+ </div>
26
+ <button
27
+ onclick="document.getElementById('new-schedule-form').classList.toggle('hidden')"
28
+ class="inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
29
+ >
30
+ <svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /></svg>
31
+ New Schedule
32
+ </button>
23
33
  </div>
24
- <div id="new-schedule-form" style="display:none;margin-bottom:1rem" class="detail">
25
- <h2>New Schedule</h2>
26
- <form method="post" action="/schedules">
27
- <div class="form-row">
28
- <label for="name">Name</label>
29
- <input type="text" name="name" id="name" required />
30
- </div>
31
- <div class="form-row">
32
- <label for="cron">Cron Expression</label>
33
- <input type="text" name="cron" id="cron" placeholder="*/30 * * * *" required />
34
- </div>
35
- <div class="form-row">
36
- <label for="prompt">Prompt</label>
37
- <textarea name="prompt" id="prompt" required></textarea>
38
- </div>
39
- <div class="form-row">
40
- <label for="workdir">Working Directory</label>
41
- <input type="text" name="workdir" id="workdir" placeholder="." />
42
- </div>
43
- <button type="submit" class="btn btn-primary">Create</button>
44
- </form>
34
+
35
+ <div id="new-schedule-form" class="hidden mb-6">
36
+ <div class="rounded-lg border border-border bg-card p-5">
37
+ <h2 class="text-base font-semibold text-card-foreground mb-4">Create Schedule</h2>
38
+ <form method="post" action="/schedules">
39
+ <div class="grid gap-4">
40
+ <div>
41
+ <label for="name" class="block text-sm font-medium text-foreground mb-1.5">Name</label>
42
+ <input type="text" name="name" id="name" required
43
+ class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background"
44
+ placeholder="Schedule name"
45
+ />
46
+ </div>
47
+ <div>
48
+ <label for="cron" class="block text-sm font-medium text-foreground mb-1.5">Cron Expression</label>
49
+ <input type="text" name="cron" id="cron" required
50
+ class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground font-mono placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background"
51
+ placeholder="*/30 * * * *"
52
+ />
53
+ </div>
54
+ <div>
55
+ <label for="prompt" class="block text-sm font-medium text-foreground mb-1.5">Prompt</label>
56
+ <textarea name="prompt" id="prompt" required rows={3}
57
+ class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background resize-y"
58
+ placeholder="What should the agent do?"
59
+ ></textarea>
60
+ </div>
61
+ <div>
62
+ <label for="workdir" class="block text-sm font-medium text-foreground mb-1.5">Working Directory</label>
63
+ <input type="text" name="workdir" id="workdir"
64
+ class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground font-mono placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background"
65
+ placeholder="."
66
+ />
67
+ </div>
68
+ <div class="flex justify-end gap-2">
69
+ <button type="button"
70
+ onclick="document.getElementById('new-schedule-form').classList.add('hidden')"
71
+ class="rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-accent transition-colors"
72
+ >Cancel</button>
73
+ <button type="submit"
74
+ class="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
75
+ >Create</button>
76
+ </div>
77
+ </div>
78
+ </form>
79
+ </div>
80
+ </div>
81
+
82
+ <div class="rounded-lg border border-border overflow-hidden">
83
+ <table class="w-full">
84
+ <thead>
85
+ <tr class="border-b border-border bg-muted/50">
86
+ <th class="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">Name</th>
87
+ <th class="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">Cron</th>
88
+ <th class="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">Status</th>
89
+ <th class="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">Last Run</th>
90
+ <th class="text-right px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">Actions</th>
91
+ </tr>
92
+ </thead>
93
+ <tbody class="divide-y divide-border">
94
+ {schedules.map((s) => {
95
+ const lastRun = getLastRun(db, s.id);
96
+ return (
97
+ <tr class="hover:bg-muted/30 transition-colors" key={s.id}>
98
+ <td class="px-4 py-3 text-sm font-medium text-foreground">{s.name}</td>
99
+ <td class="px-4 py-3 text-sm text-muted-foreground font-mono">{s.cron}</td>
100
+ <td class="px-4 py-3">
101
+ {s.enabled
102
+ ? <span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-md border bg-emerald-500/15 text-emerald-400 border-emerald-500/30">enabled</span>
103
+ : <span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-md border bg-zinc-700/30 text-zinc-500 border-zinc-600/50">disabled</span>
104
+ }
105
+ </td>
106
+ <td class="px-4 py-3 text-sm">
107
+ {lastRun ? <StatusBadge status={lastRun.status} /> : <span class="text-muted-foreground">Never</span>}
108
+ </td>
109
+ <td class="px-4 py-3 text-right">
110
+ <div class="flex items-center justify-end gap-1.5">
111
+ <form method="post" action={`/schedules/${s.id}/toggle`}>
112
+ <button type="submit"
113
+ class="rounded-md border border-border px-2.5 py-1 text-xs font-medium text-foreground hover:bg-accent transition-colors"
114
+ >
115
+ {s.enabled ? "Disable" : "Enable"}
116
+ </button>
117
+ </form>
118
+ <form method="post" action={`/schedules/${s.id}/delete`} onsubmit="return confirm('Delete this schedule?')">
119
+ <button type="submit"
120
+ class="rounded-md bg-destructive/15 border border-destructive/30 px-2.5 py-1 text-xs font-medium text-red-400 hover:bg-destructive/25 transition-colors"
121
+ >Delete</button>
122
+ </form>
123
+ </div>
124
+ </td>
125
+ </tr>
126
+ );
127
+ })}
128
+ </tbody>
129
+ </table>
130
+ {schedules.length === 0 && (
131
+ <div class="px-4 py-8 text-center text-sm text-muted-foreground">No schedules yet.</div>
132
+ )}
45
133
  </div>
46
- <table>
47
- <thead>
48
- <tr>
49
- <th>Name</th>
50
- <th>Cron</th>
51
- <th>Enabled</th>
52
- <th>Last Run</th>
53
- <th>Actions</th>
54
- </tr>
55
- </thead>
56
- <tbody>
57
- {schedules.map((s) => {
58
- const lastRun = getLastRun(db, s.id);
59
- return (
60
- <tr key={s.id}>
61
- <td>{s.name}</td>
62
- <td><code>{s.cron}</code></td>
63
- <td>{s.enabled ? "Yes" : "No"}</td>
64
- <td>
65
- {lastRun ? <StatusBadge status={lastRun.status} /> : "Never"}
66
- </td>
67
- <td>
68
- <form method="post" action={`/schedules/${s.id}/toggle`} style="display:inline">
69
- <button type="submit" class="btn btn-sm btn-primary">
70
- {s.enabled ? "Disable" : "Enable"}
71
- </button>
72
- </form>
73
- {" "}
74
- <form method="post" action={`/schedules/${s.id}/delete`} style="display:inline" onsubmit="return confirm('Delete?')">
75
- <button type="submit" class="btn btn-sm btn-danger">Delete</button>
76
- </form>
77
- </td>
78
- </tr>
79
- );
80
- })}
81
- </tbody>
82
- </table>
83
134
  </Layout>
84
135
  );
85
136
  });
@@ -1,47 +1,35 @@
1
+ export const TAILWIND_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>`;
2
+
3
+ export const THEME = `
4
+ <style type="text/tailwindcss">
5
+ @theme {
6
+ --color-border: oklch(0.3 0 0);
7
+ --color-input: oklch(0.3 0 0);
8
+ --color-ring: oklch(0.55 0 0);
9
+ --color-background: oklch(0.13 0 0);
10
+ --color-foreground: oklch(0.93 0 0);
11
+ --color-card: oklch(0.16 0 0);
12
+ --color-card-foreground: oklch(0.93 0 0);
13
+ --color-muted: oklch(0.21 0 0);
14
+ --color-muted-foreground: oklch(0.55 0 0);
15
+ --color-accent: oklch(0.21 0 0);
16
+ --color-accent-foreground: oklch(0.93 0 0);
17
+ --color-destructive: oklch(0.55 0.2 25);
18
+ --color-primary: oklch(0.93 0 0);
19
+ --color-primary-foreground: oklch(0.13 0 0);
20
+ --color-secondary: oklch(0.21 0 0);
21
+ --color-secondary-foreground: oklch(0.93 0 0);
22
+ --radius-lg: 0.5rem;
23
+ --radius-md: calc(var(--radius-lg) - 2px);
24
+ --radius-sm: calc(var(--radius-lg) - 4px);
25
+ }
26
+ </style>
27
+ `;
28
+
1
29
  export const STYLES = `
2
- * { margin: 0; padding: 0; box-sizing: border-box; }
3
- body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f8f9fa; color: #1a1a2e; line-height: 1.5; }
4
- nav { background: #1a1a2e; color: #fff; padding: 0.75rem 1.5rem; }
5
- .nav-inner { max-width: 1200px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; }
6
- .logo { color: #fff; text-decoration: none; font-weight: 700; font-size: 1.1rem; }
7
- .nav-links a { color: #cbd5e1; text-decoration: none; margin-left: 1.5rem; font-size: 0.9rem; }
8
- .nav-links a:hover { color: #fff; }
9
- main { max-width: 1200px; margin: 1.5rem auto; padding: 0 1.5rem; }
10
- h1 { margin-bottom: 1rem; font-size: 1.5rem; }
11
- h2 { margin-bottom: 0.75rem; font-size: 1.25rem; }
12
- h3 { margin-bottom: 0.5rem; font-size: 1rem; text-transform: capitalize; }
13
- .board { display: flex; gap: 1rem; overflow-x: auto; }
14
- .board-column { flex: 1; min-width: 200px; background: #e9ecef; border-radius: 8px; padding: 0.75rem; }
15
- .board-column h3 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; color: #495057; }
16
- .count { font-weight: 400; color: #868e96; }
17
- .card { display: block; background: #fff; border-radius: 6px; padding: 0.75rem; margin-top: 0.5rem; text-decoration: none; color: inherit; box-shadow: 0 1px 2px rgba(0,0,0,0.06); transition: box-shadow 0.15s; }
18
- .card:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.12); }
19
- .card-title { font-weight: 500; font-size: 0.9rem; }
20
- .card-meta { font-size: 0.75rem; color: #868e96; margin-top: 0.25rem; }
21
- .badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 999px; color: #fff; font-size: 0.75rem; font-weight: 500; }
22
- table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
23
- th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid #dee2e6; font-size: 0.9rem; }
24
- th { background: #e9ecef; font-weight: 600; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; }
25
- tr:hover td { background: #f1f3f5; }
26
- .detail { background: #fff; border-radius: 8px; padding: 1.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.08); margin-bottom: 1rem; }
27
- .detail h1 { display: flex; align-items: center; gap: 0.75rem; }
28
- .detail-meta { color: #868e96; font-size: 0.85rem; margin-bottom: 1rem; }
29
- .description { white-space: pre-wrap; margin: 1rem 0; padding: 1rem; background: #f8f9fa; border-radius: 6px; }
30
- .comment { border-left: 3px solid #dee2e6; padding: 0.5rem 0.75rem; margin: 0.75rem 0; }
31
- .comment-author { font-weight: 600; font-size: 0.85rem; }
32
- .comment-date { color: #868e96; font-size: 0.75rem; }
33
- form { margin: 1rem 0; }
34
- label { display: block; font-weight: 500; margin-bottom: 0.25rem; font-size: 0.9rem; }
35
- input[type="text"], input[type="password"], textarea, select { width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; font-size: 0.9rem; font-family: inherit; }
36
- textarea { min-height: 80px; resize: vertical; }
37
- .form-row { margin-bottom: 0.75rem; }
38
- button, .btn { display: inline-block; padding: 0.5rem 1rem; border: none; border-radius: 4px; font-size: 0.9rem; cursor: pointer; text-decoration: none; font-family: inherit; }
39
- .btn-primary { background: #3b82f6; color: #fff; }
40
- .btn-primary:hover { background: #2563eb; }
41
- .btn-danger { background: #ef4444; color: #fff; }
42
- .btn-danger:hover { background: #dc2626; }
43
- .btn-sm { padding: 0.25rem 0.5rem; font-size: 0.8rem; }
44
- .actions { display: flex; gap: 0.5rem; margin-top: 1rem; }
45
- .login-box { max-width: 360px; margin: 4rem auto; }
46
- .flash { padding: 0.75rem 1rem; border-radius: 4px; margin-bottom: 1rem; background: #fef3cd; color: #856404; }
30
+ body {
31
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
32
+ -webkit-font-smoothing: antialiased;
33
+ -moz-osx-font-smoothing: grayscale;
34
+ }
47
35
  `;