kandown 0.2.3 → 0.3.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/README.md +13 -7
- package/bin/kandown.js +172 -11
- package/bin/tui.js +476 -267
- package/dist/index.html +407 -394
- package/package.json +7 -2
- package/templates/README.md +8 -7
- package/templates/AGENT_KANDOWN_COMPACT.md +0 -65
package/README.md
CHANGED
|
@@ -4,8 +4,6 @@
|
|
|
4
4
|
|
|
5
5
|
# Kandown
|
|
6
6
|
|
|
7
|
-
> **⚠️ This project is not finished yet. V1 coming soon.**
|
|
8
|
-
|
|
9
7
|
A file-based Kanban engine backed by plain markdown. Zero backend, zero database, no account, AI-agent friendly.
|
|
10
8
|
|
|
11
9
|
Kandown installs a self-contained web app into a project folder. The app reads and writes local markdown files through the browser File System Access API, so your board stays in your repo, remains git-diffable, and can be edited by humans or AI agents without a hosted service.
|
|
@@ -62,14 +60,16 @@ Firefox and Safari do not currently support the required File System Access API.
|
|
|
62
60
|
- **Custom columns**: Add, rename, and delete columns from the board; unknown task statuses appear as temporary columns until added to settings.
|
|
63
61
|
- **Drag and drop**: Move cards between columns with optimistic file writes.
|
|
64
62
|
- **Guarded card deletion**: Hover a card and click the trash icon twice to delete a task without opening the drawer.
|
|
65
|
-
- **Task drawer**: Edit title, enabled metadata fields, subtasks, and body content.
|
|
63
|
+
- **Task drawer**: Edit title, enabled metadata fields, subtasks, and body content via a WYSIWYG markdown editor (Wysimark).
|
|
66
64
|
- **Content search**: Search titles, ids, task body, subtasks, tags, assignee, and priority with highlighted previews.
|
|
67
65
|
- **Command palette**: `⌘K` / `Ctrl+K` for task search and quick actions.
|
|
68
66
|
- **Owner type filtering**: Separate human tasks from AI-agent tasks.
|
|
69
67
|
- **Dense settings**: Sidebar search, compact setting controls, and hover help explain project options.
|
|
70
68
|
- **Configurable notifications**: Chrome permission, status-change alerts, debounced task-edit alerts, subtask-completion alerts, and in-page sound cues.
|
|
71
|
-
- **
|
|
69
|
+
- **External-change detection**: Warns when a task file is modified outside the app and offers Reload / Overwrite / Cancel choices via ConflictModal.
|
|
70
|
+
- **Appearance system**: Project-level `auto` / `light` / `dark`, built-in skins, fonts, animated WebGL backgrounds (LiquidEther fluid simulation), and local font presets.
|
|
72
71
|
- **Recent projects**: Stored in IndexedDB so local handles can be reopened quickly.
|
|
72
|
+
- **i18n**: 48 languages supported.
|
|
73
73
|
- **Single-file publish artifact**: Vite bundles the web UI into `dist/index.html`.
|
|
74
74
|
|
|
75
75
|
## Keyboard Shortcuts
|
|
@@ -147,7 +147,7 @@ The board TUI is a full-screen terminal kanban built with [Ink](https://github.c
|
|
|
147
147
|
|
|
148
148
|
**Agent picker** (`a` key): shows only agents currently installed in your `PATH`. Selecting one:
|
|
149
149
|
1. Sets the task frontmatter `status` to **In Progress**.
|
|
150
|
-
2. Constructs a system prompt from `
|
|
150
|
+
2. Constructs a system prompt from `AGENT_KANDOWN.md` + the task file.
|
|
151
151
|
3. Writes context to `/tmp/kandown-<id>-context.md` for reference.
|
|
152
152
|
4. If inside **tmux**: opens the agent in a new 50%-wide right pane (the TUI stays visible).
|
|
153
153
|
5. If not in tmux: exits the TUI and hands the terminal to the agent.
|
|
@@ -192,7 +192,7 @@ kandown/
|
|
|
192
192
|
│ └── styles/ # Tailwind layers and CSS tokens
|
|
193
193
|
├── templates/ # files copied by `kandown init`
|
|
194
194
|
│ ├── AGENT_KANDOWN.md # full agent instructions (copied to project root)
|
|
195
|
-
│ └──
|
|
195
|
+
│ └── AGENT_KANDOWN.md # agent instructions (single source of truth)
|
|
196
196
|
├── dist/index.html # generated single-file web app
|
|
197
197
|
├── tailwind.config.js
|
|
198
198
|
├── vite.config.ts
|
|
@@ -289,7 +289,7 @@ Project-level preferences:
|
|
|
289
289
|
|
|
290
290
|
Disabled fields are hidden from the task drawer, cards, list view, and metadata filters. `board.defaultPriority` only applies when `fields.priority` is enabled, and `board.defaultOwnerType` only applies when `fields.ownerType` is enabled.
|
|
291
291
|
|
|
292
|
-
Notifications are driven by
|
|
292
|
+
Notifications are driven by a 500ms polling file watcher that computes SHA-256 hashes of task file content to detect external changes. Status changes fire when task frontmatter `status` changes, task edit notifications fire after `notifications.editDebounceMs` with a minimum 2 second delay, and subtask completion notifications fire when a checklist item flips from open to done. Browser notifications require Chrome permission; sound notifications play inside the open board tab.
|
|
293
293
|
|
|
294
294
|
## Appearance Architecture
|
|
295
295
|
|
|
@@ -301,6 +301,7 @@ Important pieces:
|
|
|
301
301
|
- `tailwind.config.js` maps `background`, `foreground`, `card`, `primary`, `secondary`, `muted`, `accent`, `destructive`, plus legacy aliases like `bg`, `fg`, and `border`.
|
|
302
302
|
- `src/styles/globals.css` provides default CSS variables and shared component classes.
|
|
303
303
|
- `src/components/SettingsPage.tsx` exposes mode, skin, and font controls per project.
|
|
304
|
+
- `src/components/LiquidEther.tsx` provides an animated WebGL fluid simulation background (GPU-accelerated, available as a background option in appearance settings).
|
|
304
305
|
|
|
305
306
|
Supported theme modes:
|
|
306
307
|
|
|
@@ -348,6 +349,7 @@ Built-in fonts:
|
|
|
348
349
|
| `Card.tsx` | `Card` | Task card with progress, metadata, guarded hover deletion, drag handlers, and search previews. |
|
|
349
350
|
| `ListView.tsx` | `ListView` | Dense table-like view sharing the same filters/search cache as board view. |
|
|
350
351
|
| `Drawer.tsx` | `Drawer` | Task detail editor for title, enabled frontmatter metadata, subtasks, and body content. |
|
|
352
|
+
| `MarkdownEditor.tsx` | `MarkdownEditor` | WYSIWYG markdown body editor (Wysimark) for task body content. |
|
|
351
353
|
| `SubtaskItem.tsx` | `SubtaskItem` | Editable markdown checklist row. |
|
|
352
354
|
| `FilterBar.tsx` | `FilterBar` | Search input, owner filter, active chips, clear action. |
|
|
353
355
|
| `CommandPalette.tsx` | `CommandPalette` | Quick actions, task lookup, content search, keyboard navigation. |
|
|
@@ -356,6 +358,8 @@ Built-in fonts:
|
|
|
356
358
|
| `EmptyState.tsx` | `EmptyState` | First-run folder picker and recent projects. |
|
|
357
359
|
| `Toaster.tsx` | `Toaster` | Animated notification stack. |
|
|
358
360
|
| `Icons.tsx` | `Icon` | Local SVG icon set for app chrome; board column status glyphs use `@tabler/icons-react`. |
|
|
361
|
+
| `ConflictModal.tsx` | `ConflictModal` | Warns when a task file was modified externally; offers Reload / Overwrite / Cancel. |
|
|
362
|
+
| `LiquidEther.tsx` | `LiquidEther` | WebGL fluid simulation background (animated, GPU-accelerated). |
|
|
359
363
|
|
|
360
364
|
### Store And Domain Logic
|
|
361
365
|
|
|
@@ -367,6 +371,8 @@ Built-in fonts:
|
|
|
367
371
|
| `src/lib/parser.ts` | Markdown parsers/search | Parses board/task markdown, extracts/reinjects subtasks, and searches cached task content. |
|
|
368
372
|
| `src/lib/serializer.ts` | Markdown writers | Serializes board and task data back to markdown. |
|
|
369
373
|
| `src/lib/theme.ts` | Theme engine | Applies project-level light/dark tokens, skins, and font stacks. |
|
|
374
|
+
| `src/lib/i18n/index.ts` | i18n setup | 48-locale translation system with per-project language preference. |
|
|
375
|
+
| `src/lib/watcher.ts` | File watcher | 500ms polling watcher with SHA-256 content hashing to detect external file changes and drive reloads/notifications. |
|
|
370
376
|
| `src/hooks/useAnimatedNumber.ts` | `useAnimatedNumber` | Spring-animated numeric display for task counts. |
|
|
371
377
|
|
|
372
378
|
## Important Functions
|
package/bin/kandown.js
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
writeFileSync,
|
|
48
48
|
readdirSync,
|
|
49
49
|
statSync,
|
|
50
|
+
unlinkSync,
|
|
50
51
|
} from 'node:fs';
|
|
51
52
|
import { spawnSync, spawn } from 'node:child_process';
|
|
52
53
|
|
|
@@ -379,14 +380,6 @@ function parsePort(value) {
|
|
|
379
380
|
return port;
|
|
380
381
|
}
|
|
381
382
|
|
|
382
|
-
function writeText(res, status, body, headers = {}) {
|
|
383
|
-
res.writeHead(status, {
|
|
384
|
-
'Content-Type': 'text/plain; charset=utf-8',
|
|
385
|
-
...headers,
|
|
386
|
-
});
|
|
387
|
-
res.end(body);
|
|
388
|
-
}
|
|
389
|
-
|
|
390
383
|
function apiHeaders() {
|
|
391
384
|
return {
|
|
392
385
|
'Access-Control-Allow-Origin': '*',
|
|
@@ -400,8 +393,174 @@ function handleCors(res) {
|
|
|
400
393
|
res.end();
|
|
401
394
|
}
|
|
402
395
|
|
|
403
|
-
function
|
|
404
|
-
|
|
396
|
+
function writeJson(res, status, body) {
|
|
397
|
+
const headers = { ...apiHeaders(), 'Content-Type': 'application/json' };
|
|
398
|
+
res.writeHead(status, headers);
|
|
399
|
+
res.end(JSON.stringify(body));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function writeText(res, status, body, contentType = 'text/plain; charset=utf-8') {
|
|
403
|
+
res.writeHead(status, { ...apiHeaders(), 'Content-Type': contentType });
|
|
404
|
+
res.end(body);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function readBody(req) {
|
|
408
|
+
return new Promise((resolve, reject) => {
|
|
409
|
+
const chunks = [];
|
|
410
|
+
req.on('data', chunk => chunks.push(chunk));
|
|
411
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
412
|
+
req.on('error', reject);
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function isValidTaskId(id) {
|
|
417
|
+
return /^[a-zA-Z0-9_-]+$/.test(id);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function getConfig(res, kandownDir) {
|
|
421
|
+
const configPath = join(kandownDir, 'kandown.json');
|
|
422
|
+
if (!existsSync(configPath)) {
|
|
423
|
+
writeJson(res, 404, { error: 'kandown.json not found' });
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
try {
|
|
427
|
+
const content = readFileSync(configPath, 'utf8');
|
|
428
|
+
const config = JSON.parse(content);
|
|
429
|
+
writeJson(res, 200, config);
|
|
430
|
+
} catch (e) {
|
|
431
|
+
writeJson(res, 500, { error: `Failed to read config: ${e.message}` });
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function putConfig(req, res, kandownDir) {
|
|
436
|
+
readBody(req).then(body => {
|
|
437
|
+
try {
|
|
438
|
+
JSON.parse(body);
|
|
439
|
+
const configPath = join(kandownDir, 'kandown.json');
|
|
440
|
+
writeFileSync(configPath, body, 'utf8');
|
|
441
|
+
writeJson(res, 200, { ok: true });
|
|
442
|
+
} catch (e) {
|
|
443
|
+
writeJson(res, 400, { error: 'Invalid JSON' });
|
|
444
|
+
}
|
|
445
|
+
}).catch(e => {
|
|
446
|
+
writeJson(res, 500, { error: `Failed to read body: ${e.message}` });
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function getBoard(res, kandownDir) {
|
|
451
|
+
const boardPath = join(kandownDir, 'board.md');
|
|
452
|
+
if (!existsSync(boardPath)) {
|
|
453
|
+
writeText(res, 404, 'board.md not found');
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
try {
|
|
457
|
+
const content = readFileSync(boardPath, 'utf8');
|
|
458
|
+
writeText(res, 200, content);
|
|
459
|
+
} catch (e) {
|
|
460
|
+
writeText(res, 500, `Failed to read board: ${e.message}`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function putBoard(req, res, kandownDir) {
|
|
465
|
+
readBody(req).then(body => {
|
|
466
|
+
const boardPath = join(kandownDir, 'board.md');
|
|
467
|
+
writeFileSync(boardPath, body, 'utf8');
|
|
468
|
+
writeJson(res, 200, { ok: true });
|
|
469
|
+
}).catch(e => {
|
|
470
|
+
writeJson(res, 500, { error: `Failed to write board: ${e.message}` });
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function getTasks(res, kandownDir) {
|
|
475
|
+
const tasksDir = join(kandownDir, 'tasks');
|
|
476
|
+
if (!existsSync(tasksDir)) {
|
|
477
|
+
writeJson(res, 200, []);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
try {
|
|
481
|
+
const files = readdirSync(tasksDir).filter(f => f.endsWith('.md'));
|
|
482
|
+
const ids = files.map(f => f.replace(/\.md$/, ''));
|
|
483
|
+
writeJson(res, 200, ids);
|
|
484
|
+
} catch (e) {
|
|
485
|
+
writeJson(res, 500, { error: `Failed to list tasks: ${e.message}` });
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function getTask(res, kandownDir, id) {
|
|
490
|
+
if (!isValidTaskId(id)) {
|
|
491
|
+
writeText(res, 400, 'Invalid task id');
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
const taskPath = join(kandownDir, 'tasks', `${id}.md`);
|
|
495
|
+
if (!existsSync(taskPath)) {
|
|
496
|
+
writeText(res, 404, 'Task not found');
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
try {
|
|
500
|
+
const content = readFileSync(taskPath, 'utf8');
|
|
501
|
+
writeText(res, 200, content);
|
|
502
|
+
} catch (e) {
|
|
503
|
+
writeText(res, 500, `Failed to read task: ${e.message}`);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function putTask(req, res, kandownDir, id) {
|
|
508
|
+
if (!isValidTaskId(id)) {
|
|
509
|
+
writeText(res, 400, 'Invalid task id');
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
readBody(req).then(body => {
|
|
513
|
+
const tasksDir = join(kandownDir, 'tasks');
|
|
514
|
+
if (!existsSync(tasksDir)) mkdirSync(tasksDir, { recursive: true });
|
|
515
|
+
const taskPath = join(tasksDir, `${id}.md`);
|
|
516
|
+
writeFileSync(taskPath, body, 'utf8');
|
|
517
|
+
writeJson(res, 200, { ok: true });
|
|
518
|
+
}).catch(e => {
|
|
519
|
+
writeJson(res, 500, { error: `Failed to write task: ${e.message}` });
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function deleteTask(res, kandownDir, id) {
|
|
524
|
+
if (!isValidTaskId(id)) {
|
|
525
|
+
writeText(res, 400, 'Invalid task id');
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
const taskPath = join(kandownDir, 'tasks', `${id}.md`);
|
|
529
|
+
if (!existsSync(taskPath)) {
|
|
530
|
+
writeJson(res, 404, { error: 'Task not found' });
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
try {
|
|
534
|
+
unlinkSync(taskPath);
|
|
535
|
+
writeJson(res, 200, { ok: true });
|
|
536
|
+
} catch (e) {
|
|
537
|
+
writeJson(res, 500, { error: `Failed to delete task: ${e.message}` });
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function handleApi(req, res, url, kandownDir) {
|
|
542
|
+
const parts = url.pathname.replace('/api/', '').split('/');
|
|
543
|
+
const resource = parts[0];
|
|
544
|
+
const id = parts[1];
|
|
545
|
+
|
|
546
|
+
if (resource === 'config') {
|
|
547
|
+
if (req.method === 'GET') return getConfig(res, kandownDir);
|
|
548
|
+
if (req.method === 'PUT') return putConfig(req, res, kandownDir);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (resource === 'board') {
|
|
552
|
+
if (req.method === 'GET') return getBoard(res, kandownDir);
|
|
553
|
+
if (req.method === 'PUT') return putBoard(req, res, kandownDir);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (resource === 'tasks') {
|
|
557
|
+
if (req.method === 'GET' && !id) return getTasks(res, kandownDir);
|
|
558
|
+
if (req.method === 'GET' && id) return getTask(res, kandownDir, id);
|
|
559
|
+
if (req.method === 'PUT' && id) return putTask(req, res, kandownDir, id);
|
|
560
|
+
if (req.method === 'DELETE' && id) return deleteTask(res, kandownDir, id);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
writeJson(res, 404, { error: 'Not found' });
|
|
405
564
|
}
|
|
406
565
|
|
|
407
566
|
function serveApp(res, kandownDir) {
|
|
@@ -434,7 +593,9 @@ function createServeServer(kandownDir) {
|
|
|
434
593
|
const requestUrl = new URL(req.url || '/', 'http://localhost');
|
|
435
594
|
if (req.method === 'OPTIONS') return handleCors(res);
|
|
436
595
|
if (requestUrl.pathname === '/') return serveApp(res, kandownDir);
|
|
437
|
-
if (requestUrl.pathname.startsWith('/api/'))
|
|
596
|
+
if (requestUrl.pathname.startsWith('/api/')) {
|
|
597
|
+
return handleApi(req, res, requestUrl, kandownDir);
|
|
598
|
+
}
|
|
438
599
|
return writeText(res, 404, 'Not found');
|
|
439
600
|
});
|
|
440
601
|
}
|