kandown 0.3.2 → 0.3.4

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 CHANGED
@@ -1,226 +1,251 @@
1
1
  <p align="center">
2
- <img src="logo.svg" width="128" height="128" alt="Kandown logo">
2
+ <img src="logo.svg" width="140" height="140" alt="Kandown logo">
3
3
  </p>
4
4
 
5
- # Kandown
5
+ <h1 align="center">Kandown</h1>
6
6
 
7
- A file-based Kanban engine backed by plain markdown. Zero backend, zero database, no account, AI-agent friendly.
8
-
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.
7
+ <p align="center">
8
+ <strong>File-based Kanban board backed by plain Markdown.</strong><br>
9
+ Zero backend · Zero database · No account · AI-agent friendly
10
+ </p>
10
11
 
11
- ## Why
12
+ <p align="center">
13
+ <a href="https://www.npmjs.com/package/kandown"><img src="https://img.shields.io/npm/v/kandown?color=cb3837&label=npm" alt="npm version"></a>
14
+ <a href="https://www.npmjs.com/package/kandown"><img src="https://img.shields.io/npm/dm/kandown?color=blue" alt="npm downloads"></a>
15
+ <img src="https://img.shields.io/badge/license-MIT-green" alt="MIT License">
16
+ <img src="https://img.shields.io/badge/languages-48-purple" alt="48 languages">
17
+ <img src="https://img.shields.io/badge/local--first-100%25-orange" alt="100% local-first">
18
+ </p>
12
19
 
13
- Most kanban tools trap your data in their cloud. Kandown does the opposite: all state lives in `.kandown/` as markdown and JSON.
20
+ <p align="center">
21
+ <a href="#-quick-start">Quick Start</a> ·
22
+ <a href="#-features">Features</a> ·
23
+ <a href="#-cli-commands">CLI</a> ·
24
+ <a href="#-board-tui">Board TUI</a> ·
25
+ <a href="#-ai-agent-integration">AI Agents</a> ·
26
+ <a href="#-keyboard-shortcuts">Shortcuts</a> ·
27
+ <a href="#-development">Development</a>
28
+ </p>
14
29
 
15
- The core architecture keeps task state in the task files themselves:
30
+ ---
16
31
 
17
- - `tasks/<id>.md` stores the full task context and board state: title, status, order, metadata, subtasks, notes, and completion reports.
18
- - `kandown.json` stores project preferences such as board columns, theme mode, skin, font, agent behavior, notifications, and enabled fields.
32
+ ## 🚀 Quick Start
19
33
 
20
- That task-first model matters for AI tools. Moving or completing a task means editing one markdown file, not synchronizing an index and a detail file.
34
+ ### 1. Install globally
21
35
 
22
- ## Install & Use
36
+ ```bash
37
+ npm i -g kandown
38
+ ```
23
39
 
24
- If Kandown is published on npm:
40
+ ### 2. Initialize in any project
25
41
 
26
42
  ```bash
27
43
  cd my-project
28
- npx kandown init
44
+ kandown init
45
+ ```
46
+
47
+ ### 3. Launch your board
48
+
49
+ ```bash
50
+ kandown
29
51
  ```
30
52
 
31
- This creates:
53
+ > That's it. A local web UI opens in your browser, and an interactive board TUI appears in your terminal. All your data stays in `.kandown/` as plain markdown — versioned with git, readable by humans and AI agents alike.
54
+
55
+ ---
56
+
57
+ ## 💡 Why Kandown?
58
+
59
+ Most kanban tools trap your data in their cloud. Kandown does the opposite.
32
60
 
33
- ```text
61
+ | Traditional Kanban | Kandown |
62
+ |---|---|
63
+ | ☁️ Cloud-hosted, needs an account | 📁 100% local, no account |
64
+ | 🔒 Proprietary data format | 📝 Plain Markdown + JSON |
65
+ | 💰 Paid plans for teams | 🆓 Free & open-source (MIT) |
66
+ | 🤖 No AI integration | 🤖 AI-agent native (Claude, Codex, Gemini…) |
67
+ | 🔄 Requires sync/internet | 📂 Git-diffable, offline-first |
68
+ | 🏗️ Backend, database, infra | 🚫 Zero backend, zero dependencies |
69
+
70
+ **Your tasks are just `.md` files.** Move them, `grep` them, edit them in Vim, let an AI agent process them — they're yours.
71
+
72
+ ---
73
+
74
+ ## 📦 What Gets Installed
75
+
76
+ Running `kandown init` creates a `.kandown/` folder in your project:
77
+
78
+ ```
34
79
  .kandown/
35
- ├── kandown.html # single-file web app, built from dist/index.html
36
- ├── kandown.json # project preferences, columns, notifications, and appearance
37
- ├── tasks/ # per-task markdown files and board state
38
- ├── AGENT.md # short AI-agent rules
39
- └── README.md # user-facing project-local guide
80
+ ├── kandown.html Single-file web app (opens in any browser)
81
+ ├── kandown.json Project preferences, columns, appearance
82
+ ├── tasks/ One markdown file per task (source of truth)
83
+ ├── AGENT.md AI-agent quick reference
84
+ └── README.md Project-local user guide
40
85
  ```
41
86
 
42
- It also copies `AGENT_KANDOWN.md` to the project root and adds a reference to `AGENTS.md` / `CLAUDE.md` when possible, so AI tools know how to work with the board.
43
-
44
- To use the board:
45
-
46
- 1. Open `.kandown/kandown.html` in Chrome, Edge, Brave, or Opera.
47
- 2. Select the project folder when prompted.
48
- 3. Grant read/write permission.
49
-
50
- Firefox and Safari do not currently support the required File System Access API.
51
-
52
- ## Features
53
-
54
- - **File-over-app**: Markdown and JSON are the source of truth.
55
- - **Zero backend**: No server, database, login, or sync vendor.
56
- - **AI-agent optimized**: Task files are the single source of truth.
57
- - **Board and list views**: Toggle with `⌘1` / `⌘2`.
58
- - **Column status icons**: Board columns use Tabler icons beside titles so states like Backlog, In Progress, Review, and Done are easier to scan.
59
- - **Column color accents**: Columns can use expanded translucent background colors, including black variants.
60
- - **Custom columns**: Add, rename, and delete columns from the board; unknown task statuses appear as temporary columns until added to settings.
61
- - **Drag and drop**: Move cards between columns with optimistic file writes.
62
- - **Guarded card deletion**: Hover a card and click the trash icon twice to delete a task without opening the drawer.
63
- - **Task drawer**: Edit title, enabled metadata fields, subtasks, and body content via a WYSIWYG markdown editor (Wysimark).
64
- - **Content search**: Search titles, ids, task body, subtasks, tags, assignee, and priority with highlighted previews.
65
- - **Command palette**: `⌘K` / `Ctrl+K` for task search and quick actions.
66
- - **Owner type filtering**: Separate human tasks from AI-agent tasks.
67
- - **Dense settings**: Sidebar search, compact setting controls, and hover help explain project options.
68
- - **Configurable notifications**: Chrome permission, status-change alerts, debounced task-edit alerts, subtask-completion alerts, and in-page sound cues.
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.
71
- - **Recent projects**: Stored in IndexedDB so local handles can be reopened quickly.
72
- - **i18n**: 48 languages supported.
73
- - **Single-file publish artifact**: Vite bundles the web UI into `dist/index.html`.
74
-
75
- ## Keyboard Shortcuts
87
+ It also copies `AGENT_KANDOWN.md` to the project root and wires it into your `AGENTS.md` / `CLAUDE.md` so AI agents know how to manage your board automatically.
76
88
 
77
- | Key | Action |
89
+ ---
90
+
91
+ ## ✨ Features
92
+
93
+ ### Board & Views
94
+
95
+ | Feature | Description |
78
96
  |---|---|
79
- | `⌘K` / `Ctrl+K` | Open command palette |
80
- | `⌘1` / `Ctrl+1` | Board view |
81
- | `⌘2` / `Ctrl+2` | List view |
82
- | `N` | New task |
83
- | `R` | Reload files from disk |
84
- | `/` | Focus task search |
85
- | `Esc` | Cancel the drawer or close the command palette |
86
- | `⌘S` / `Ctrl+S` | Save current task in the drawer |
87
- | `⌘⌫` / `Ctrl+Backspace` | Delete current task in the drawer after confirmation |
97
+ | 🗂️ Board view | Horizontal kanban with drag-and-drop between columns |
98
+ | 📋 List view | Dense table-like view with the same filters & search |
99
+ | 🔍 Content search | Search titles, body, subtasks, tags, assignee, priority — with highlighted previews |
100
+ | ⌨️ Command palette | `⌘K` / `Ctrl+K` for quick actions and task lookup |
101
+ | 🏷️ Custom columns | Add, rename, delete columns — unknown statuses appear as temp columns |
102
+ | 🗑️ Guarded deletion | Double-click the trash icon to delete — no accidental losses |
88
103
 
89
- ## CLI
104
+ ### Task Management
105
+
106
+ | Feature | Description |
107
+ |---|---|
108
+ | ✏️ Rich task drawer | Edit title, metadata, subtasks, and body via WYSIWYG markdown editor |
109
+ | ☑️ Subtasks | Full checklist support with progress tracking on cards |
110
+ | 📊 Metadata fields | Priority, assignee, tags, due date, owner type — toggle each on/off |
111
+ | 👤 Owner filtering | Separate human tasks from AI-agent tasks |
112
+ | ⚡ External-change detection | Warns when a file was modified outside the app (Reload / Overwrite / Cancel) |
113
+
114
+ ### Appearance & UX
115
+
116
+ | Feature | Description |
117
+ |---|---|
118
+ | 🎨 5 built-in skins | Kandown, Graphite, Sage, Cobalt, Rose |
119
+ | 🌗 Theme modes | Auto, Light, Dark |
120
+ | 🔤 5 font stacks | Inter, System, Serif, Mono, Rounded |
121
+ | 🌊 Animated backgrounds | WebGL fluid simulation (LiquidEther) |
122
+ | 🔔 Notifications | Browser + in-page sound alerts for status changes, edits, subtask completions |
123
+ | 🌍 48 languages | Full i18n support |
124
+ | 📌 Recent projects | Stored in IndexedDB for quick reopening |
125
+
126
+ ---
127
+
128
+ ## 🖥️ CLI Commands
129
+
130
+ ### Usage
90
131
 
91
132
  ```bash
92
- # Start the local HTTP web UI + open the interactive board TUI (recommended)
93
- npx kandown
94
- npx kandown --port 3000
95
-
96
- # Board TUI only (no browser)
97
- npx kandown board
98
-
99
- # Initialize kandown in the current project
100
- npx kandown init
101
- npx kandown init --path docs/tasks
102
- npx kandown init --no-agents
103
- npx kandown init --force
104
-
105
- # Other commands
106
- npx kandown update
107
- npx kandown settings
133
+ # If installed globally (recommended):
134
+ kandown [command] [options]
135
+
136
+ # Or with npx (no install needed):
137
+ npx kandown [command] [options]
108
138
  ```
109
139
 
110
140
  ### Commands
111
141
 
112
- | Command | Purpose |
142
+ | Command | Description |
113
143
  |---|---|
114
- | *(none)* | Start a local HTTP server for `.kandown/kandown.html`, open the web UI in your default browser, and launch the board TUI. |
115
- | `board` | Open the interactive kanban board TUI only (no browser). |
116
- | `init` | Create `.kandown/`, copy templates, copy the built web app, and install agent docs. |
117
- | `update` | Replace an installed `kandown.html` with the current package build. |
118
- | `settings` | Open the Ink-based terminal settings editor for `kandown.json`. |
119
- | `help` | Print CLI help. |
144
+ | `kandown` | Launch web UI + board TUI (recommended daily workflow) |
145
+ | `kandown init` | Initialize Kandown in the current project |
146
+ | `kandown board` | Open the board TUI only (no browser) |
147
+ | `kandown settings` | Open the terminal settings editor |
148
+ | `kandown update` | Update `kandown.html` to the latest version |
149
+ | `kandown help` | Print CLI help |
120
150
 
121
- The bare `kandown` command uses a zero-dependency Node.js HTTP server. It tries `http://localhost:2048` first, then scans through port `2060` if earlier ports are busy. Use `--port <number>` to request a specific port.
151
+ ### Init Options
122
152
 
123
- ### Board TUI
153
+ | Flag | Description |
154
+ |---|---|
155
+ | `--path <dir>` | Install `.kandown` at a custom location |
156
+ | `--force` | Overwrite existing installation |
157
+ | `--no-agents` | Skip AI agent docs setup |
158
+
159
+ ### Serve Options
160
+
161
+ | Flag | Description |
162
+ |---|---|
163
+ | `--port <number>` | Use a specific port (default: auto-scan 2048–2060) |
124
164
 
125
- The board TUI is a full-screen terminal kanban built with [Ink](https://github.com/vadimdemedes/ink). It renders the same columns and tasks as the web UI, and lets you launch an AI agent on any task directly from the terminal.
165
+ ---
166
+
167
+ ## 📟 Board TUI
168
+
169
+ The board TUI is a full-screen terminal kanban built with [Ink](https://github.com/vadimdemedes/ink). Same columns and tasks as the web UI — plus the ability to launch AI agents on any task.
126
170
 
127
171
  ```
128
172
  KANDOWN tmux My Project
129
- ────────────────────────────────────────────────────────────────
173
+ ──────────────────────────────────────────────────────────────
130
174
  Backlog (3) │ Todo (2) │ In Progress │ Review (1) │ Done
131
175
  ──────────────│──────────────│──────────────│────────────│──────
132
176
  ▸ t9 │ t16 │ (empty) │ t18 │ ...
133
- t10 │ t7 │ │
177
+ t10 │ t7 │ │
134
178
  t11 │ │
135
179
  ```
136
180
 
137
- **Navigation:**
181
+ ### TUI Navigation
138
182
 
139
183
  | Key | Action |
140
184
  |---|---|
141
185
  | `h` / `l` or `←` / `→` | Move between columns |
142
- | `j` / `k` or `↑` / `↓` | Move between tasks within a column |
143
- | `Enter` | Open task detail view (scrollable) |
144
- | `a` | Open agent picker for the focused task |
145
- | `r` | Reload task files from disk |
146
- | `q` / `Esc` | Quit (or go back from detail / picker) |
147
-
148
- **Agent picker** (`a` key): shows only agents currently installed in your `PATH`. Selecting one:
149
- 1. Sets the task frontmatter `status` to **In Progress**.
150
- 2. Constructs a system prompt from `AGENT_KANDOWN.md` + the task file.
151
- 3. Writes context to `/tmp/kandown-<id>-context.md` for reference.
152
- 4. If inside **tmux**: opens the agent in a new 50%-wide right pane (the TUI stays visible).
153
- 5. If not in tmux: exits the TUI and hands the terminal to the agent.
154
-
155
- **Supported agents** (auto-detected via `which`):
156
-
157
- | Agent | Binary | Notes |
186
+ | `j` / `k` or `↑` / `↓` | Move between tasks |
187
+ | `Enter` | Open task detail (scrollable) |
188
+ | `a` | Launch AI agent picker |
189
+ | `r` | Reload files from disk |
190
+ | `q` / `Esc` | Quit or go back |
191
+
192
+ ---
193
+
194
+ ## 🤖 AI Agent Integration
195
+
196
+ Press `a` in the board TUI to launch an AI agent on the selected task. Kandown auto-detects installed agents and builds a context-rich prompt from `AGENT_KANDOWN.md` + the task file.
197
+
198
+ ### Supported Agents
199
+
200
+ | Agent | Binary | Launch mode |
158
201
  |---|---|---|
159
- | Claude Code | `claude` | Interactive session with initial prompt |
160
- | OpenAI Codex | `codex` | Interactive session |
161
- | Gemini CLI | `gemini` | `-p` flag for initial prompt |
162
- | Goose | `goose` | `run --text` for non-interactive |
163
- | Aider | `aider` | `--message` for initial prompt |
164
- | OpenCode | `opencode` | TUI, context written to `/tmp` |
202
+ | **Claude Code** | `claude` | Interactive session with initial prompt |
203
+ | **OpenAI Codex** | `codex` | Interactive session |
204
+ | **Gemini CLI** | `gemini` | `-p` flag for initial prompt |
205
+ | **Goose** | `goose` | `run --text` for non-interactive |
206
+ | **Aider** | `aider` | `--message` for initial prompt |
207
+ | **OpenCode** | `opencode` | TUI, context written to `/tmp` |
165
208
 
166
- ## Project Architecture
209
+ ### How it works
167
210
 
168
- ```text
169
- kandown/
170
- ├── bin/
171
- │ ├── kandown.js # npm CLI entrypoint (hand-rolled, no deps)
172
- │ └── tui.js # generated TUI bundle from tsup
173
- ├── src/
174
- │ ├── App.tsx # web app shell and global shortcuts
175
- │ ├── main.tsx # React/Vite browser entrypoint
176
- │ ├── cli/ # Ink terminal UI source
177
- │ │ ├── tui.tsx # Ink entrypoint — alternate screen buffer
178
- │ │ ├── app.tsx # TUI screen router (board, settings)
179
- │ │ ├── lib/
180
- │ │ │ ├── config.ts # kandown.json reader/writer
181
- │ │ │ ├── board-reader.ts # Node fs task scanner + moveTaskToColumn
182
- │ │ │ ├── agents.ts # AI agent registry, detection, prompt builder
183
- │ │ │ └── launcher.ts # Process spawning (tmux / direct exec)
184
- │ │ └── screens/
185
- │ │ ├── board.tsx # Interactive kanban board TUI
186
- │ │ ├── agent-picker.tsx # Agent selection overlay
187
- │ │ └── settings.tsx # Settings editor TUI
188
- │ ├── components/ # React UI components (web only)
189
- │ ├── hooks/ # small React hooks
190
- │ ├── lib/ # domain model, parser, serializer, store, theme, filesystem
191
- │ ├── logo.svg
192
- │ └── styles/ # Tailwind layers and CSS tokens
193
- ├── templates/ # files copied by `kandown init`
194
- │ ├── AGENT_KANDOWN.md # full agent instructions (copied to project root)
195
- │ └── AGENT_KANDOWN.md # agent instructions (single source of truth)
196
- ├── dist/index.html # generated single-file web app
197
- ├── tailwind.config.js
198
- ├── vite.config.ts
199
- ├── tsup.config.ts
200
- └── package.json
201
- ```
211
+ 1. Selecting an agent sets the task status to **In Progress**
212
+ 2. A system prompt is built from `AGENT_KANDOWN.md` + the task file
213
+ 3. Context is written to `/tmp/kandown-<id>-context.md`
214
+ 4. **In tmux**: opens in a 50%-wide split pane (TUI stays visible)
215
+ 5. **Without tmux**: exits TUI and hands the terminal to the agent
202
216
 
203
- ## Runtime Flow
217
+ > **Tip:** AI agents can also edit task files directly — Kandown detects external changes and syncs automatically.
204
218
 
205
- 1. `main.tsx` mounts `App`.
206
- 2. `App` renders the header and either `EmptyState`, `Board`, `ListView`, or `SettingsPage`.
207
- 3. The user selects a folder with the File System Access API.
208
- 4. `filesystem.ts` resolves or creates `.kandown/`, `tasks/`, and `kandown.json`.
209
- 5. `store.ts` loads config, applies appearance tokens, scans task files, and keeps recent project handles in IndexedDB.
210
- 6. `parser.ts` converts task markdown into typed board/task data.
211
- 7. React components render the board/list/drawer.
212
- 8. Mutations go back through store actions, then through `serializer.ts` and `filesystem.ts`.
219
+ ---
213
220
 
214
- ## Data Model
221
+ ## ⌨️ Keyboard Shortcuts
215
222
 
216
- ### `tasks/<id>.md`
223
+ ### Web UI
217
224
 
218
- Task files store rich context and board state.
225
+ | Shortcut | Action |
226
+ |---|---|
227
+ | `⌘K` / `Ctrl+K` | Command palette |
228
+ | `⌘1` / `Ctrl+1` | Board view |
229
+ | `⌘2` / `Ctrl+2` | List view |
230
+ | `N` | New task |
231
+ | `R` | Reload from disk |
232
+ | `/` | Focus search |
233
+ | `Esc` | Close drawer / palette |
234
+ | `⌘S` / `Ctrl+S` | Save task in drawer |
235
+ | `⌘⌫` / `Ctrl+Backspace` | Delete task (with confirmation) |
236
+
237
+ ---
238
+
239
+ ## 📄 Data Model
240
+
241
+ ### Task files — `tasks/<id>.md`
242
+
243
+ Each task is a standalone markdown file with YAML frontmatter:
219
244
 
220
245
  ```markdown
221
246
  ---
222
247
  id: t1
223
- title: Full task title
248
+ title: Implement user auth
224
249
  status: Todo
225
250
  order: 0
226
251
  priority: P1
@@ -230,25 +255,24 @@ created: 2026-04-10
230
255
  ownerType: human
231
256
  ---
232
257
 
233
- # Task title
258
+ # Implement user auth
234
259
 
235
260
  ## Context
236
-
237
- Why this task exists.
261
+ Why this task exists and what it solves.
238
262
 
239
263
  ## Subtasks
240
-
241
- - [ ] First step
242
- - [x] Second step
264
+ - [ ] Set up OAuth provider
265
+ - [x] Create user model
266
+ - [ ] Add session middleware
243
267
 
244
268
  ## Notes
245
-
246
- Extra details.
269
+ Extra details, links, decisions.
247
270
  ```
248
271
 
249
- ### `kandown.json`
272
+ ### Configuration — `kandown.json`
250
273
 
251
- Project-level preferences:
274
+ <details>
275
+ <summary>Full default configuration</summary>
252
276
 
253
277
  ```json
254
278
  {
@@ -287,315 +311,182 @@ Project-level preferences:
287
311
  }
288
312
  ```
289
313
 
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
-
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
-
294
- ## Appearance Architecture
295
-
296
- Kandown uses shadcn-compatible CSS variables without depending on shadcn components. The app keeps its own source components, while Tailwind aliases point at token variables.
297
-
298
- Important pieces:
299
-
300
- - `src/lib/theme.ts` defines skin ids, font ids, token maps, validators, and `applyProjectTheme`.
301
- - `tailwind.config.js` maps `background`, `foreground`, `card`, `primary`, `secondary`, `muted`, `accent`, `destructive`, plus legacy aliases like `bg`, `fg`, and `border`.
302
- - `src/styles/globals.css` provides default CSS variables and shared component classes.
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).
305
-
306
- Supported theme modes:
307
-
308
- | Value | Behavior |
309
- |---|---|
310
- | `auto` | Follow `prefers-color-scheme`. |
311
- | `light` | Force light tokens. |
312
- | `dark` | Force dark tokens. |
313
-
314
- Built-in skins:
314
+ </details>
315
315
 
316
- | Skin | Intent |
316
+ | Section | Key options |
317
317
  |---|---|
318
- | `kandown` | Crisp neutral default, close to the original UI. |
319
- | `graphite` | Soft gray surfaces. |
320
- | `sage` | Calm green-gray planning palette. |
321
- | `cobalt` | Cool blue accent, restrained surfaces. |
322
- | `rose` | Warm ink with a restrained rose accent. |
323
-
324
- Built-in fonts:
318
+ | `ui` | Language (48 locales), theme mode, skin, font |
319
+ | `board` | Task ID prefix, default priority/owner |
320
+ | `fields` | Toggle visibility of priority, assignee, tags, due date, owner type |
321
+ | `notifications` | Browser alerts, sound cues, debounce timing |
325
322
 
326
- | Font | Stack |
327
- |---|---|
328
- | `inter` | Inter-first sans stack. |
329
- | `system` | Native platform sans stack. |
330
- | `serif` | Editorial serif stack. |
331
- | `mono` | Monospace stack. |
332
- | `rounded` | Rounded system stack. |
323
+ Disabled fields are hidden from cards, list view, the task drawer, and metadata filters.
333
324
 
334
- ## Web Modules
325
+ ---
335
326
 
336
- ### App Shell
327
+ ## 🌐 Browser Support
337
328
 
338
- | File | Role |
329
+ | Browser | Supported |
339
330
  |---|---|
340
- | `src/main.tsx` | Mounts React and global CSS. |
341
- | `src/App.tsx` | Composes the web UI and owns global keyboard shortcuts. |
331
+ | Chrome | |
332
+ | Edge | |
333
+ | Brave | ✅ |
334
+ | Opera | ✅ |
335
+ | Firefox | ❌ (no File System Access API) |
336
+ | Safari | ❌ (no File System Access API) |
342
337
 
343
- ### Components
338
+ > **Server mode** (`kandown` CLI command) works in all browsers — it proxies file operations through a local REST API, bypassing the File System Access API requirement.
344
339
 
345
- | File | Main exports | Description |
346
- |---|---|---|
347
- | `Board.tsx` | `Board` | Horizontal kanban view, filtering, drag state, search-match forwarding. |
348
- | `Column.tsx` | `Column` | One kanban column, Tabler status icon, drop target, create-task button, empty state. |
349
- | `Card.tsx` | `Card` | Task card with progress, metadata, guarded hover deletion, drag handlers, and search previews. |
350
- | `ListView.tsx` | `ListView` | Dense table-like view sharing the same filters/search cache as board view. |
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. |
353
- | `SubtaskItem.tsx` | `SubtaskItem` | Editable markdown checklist row. |
354
- | `FilterBar.tsx` | `FilterBar` | Search input, owner filter, active chips, clear action. |
355
- | `CommandPalette.tsx` | `CommandPalette` | Quick actions, task lookup, content search, keyboard navigation. |
356
- | `SettingsPage.tsx` | `SettingsPage` | Dense project settings with sidebar search, compact controls, and contextual hover help. |
357
- | `Header.tsx` | `Header` | Project switcher, view controls, settings link, reload, new task. |
358
- | `EmptyState.tsx` | `EmptyState` | First-run folder picker and recent projects. |
359
- | `Toaster.tsx` | `Toaster` | Animated notification stack. |
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). |
363
-
364
- ### Store And Domain Logic
365
-
366
- | File | Main exports | Description |
367
- |---|---|---|
368
- | `src/lib/types.ts` | `DEFAULT_CONFIG`, domain types | Shared TypeScript contracts for board, tasks, config, filters, search, and appearance. |
369
- | `src/lib/store.ts` | `useStore` | Zustand state machine for project loading, board mutations, drawer editing, search cache, config, and toasts. |
370
- | `src/lib/filesystem.ts` | File and IndexedDB helpers | Browser File System Access API and recent-project persistence. |
371
- | `src/lib/parser.ts` | Markdown parsers/search | Parses board/task markdown, extracts/reinjects subtasks, and searches cached task content. |
372
- | `src/lib/serializer.ts` | Markdown writers | Serializes board and task data back to markdown. |
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. |
376
- | `src/hooks/useAnimatedNumber.ts` | `useAnimatedNumber` | Spring-animated numeric display for task counts. |
340
+ ---
377
341
 
378
- ## Important Functions
342
+ ## 🎨 Appearance
379
343
 
380
- ### `src/lib/store.ts`
344
+ ### Skins
381
345
 
382
- | Function/action | Description |
346
+ | Skin | Description |
383
347
  |---|---|
384
- | `openFolder` | Prompts for a project folder, resolves `.kandown`, saves recent project, loads config and board. |
385
- | `openRecentProject` | Reopens an IndexedDB-stored project handle after permission verification. |
386
- | `loadConfig` | Reads `kandown.json`, merges defaults, applies theme tokens. |
387
- | `updateConfig` | Optimistically updates config, applies appearance immediately, writes `kandown.json`. |
388
- | `reloadBoard` | Scans task files, derives columns from task statuses, and eagerly loads task content for small boards. |
389
- | `moveTask` | Optimistically moves a task between columns and writes task frontmatter `status` / `order`, rolling back on failure. |
390
- | `reorderInColumn` | Reorders tasks within one column by writing task frontmatter `order`. |
391
- | `addColumn` / `renameColumn` / `deleteColumn` | Manage `board.columns` in config and update affected task files. |
392
- | `createTask` | Creates a task markdown file with initial frontmatter. |
393
- | `deleteTask` | Deletes the task file and clears cached content/search matches. |
394
- | `openDrawer` | Reads one task detail file and prepares editable drawer state. |
395
- | `saveDrawer` | Writes full task detail content and closes the drawer. |
396
- | `saveDrawerMetadata` | Autosaves task detail content and reloads derived board metadata/progress. |
397
- | `setFilter` | Updates filters and lazily loads task contents for search queries. |
398
- | `loadTaskContents` | Reads task files into the content-search cache. |
399
- | `computeSearchMatches` | Produces per-task search matches for board/list previews. |
400
- | `toast` | Adds transient UI messages. |
401
-
402
- ### `src/lib/notifications.ts`
403
-
404
- | Function | Description |
405
- |---|---|
406
- | `getBrowserNotificationPermission` | Reads browser notification permission and reports unsupported browsers. |
407
- | `requestBrowserNotificationPermission` | Prompts Chrome-compatible browsers from the Settings page. |
408
- | `emitKandownNotification` | Dispatches enabled browser notifications and in-page sound cues. |
409
- | `playNotificationSound` | Plays generated Web Audio cues without external assets. |
348
+ | `kandown` | Crisp neutral default |
349
+ | `graphite` | Soft gray surfaces |
350
+ | `sage` | Calm green-gray planning palette |
351
+ | `cobalt` | Cool blue accent, restrained surfaces |
352
+ | `rose` | Warm ink with rose accent |
410
353
 
411
- ### `src/lib/parser.ts`
354
+ ### Theme Modes
412
355
 
413
- | Function | Description |
356
+ | Mode | Behavior |
414
357
  |---|---|
415
- | `parseSimpleYaml` | Parses Kandown's limited frontmatter format. |
416
- | `parseTaskFile` | Splits a task markdown file into frontmatter and body. |
417
- | `taskToBoardTask` | Converts parsed task frontmatter/body into compact card metadata. |
418
- | `buildColumnsFromTasks` | Groups parsed task files into configured and temporary columns. |
419
- | `extractSubtasks` | Pulls markdown checklist lines out of a subtask section. |
420
- | `injectSubtasks` | Writes edited subtasks back into a task body. |
421
- | `searchTaskContent` | Searches title, subtasks, body, tags, assignee, and priority with contextual snippets. |
358
+ | `auto` | Follows system `prefers-color-scheme` |
359
+ | `light` | Force light theme |
360
+ | `dark` | Force dark theme |
422
361
 
423
- ### `src/lib/filesystem.ts`
362
+ ### Fonts
424
363
 
425
- | Function | Description |
426
- |---|---|
427
- | `supportsFileSystemAccess` | Detects required browser API support. |
428
- | `pickProjectDirectory` | Prompts for a project folder and opens or creates `.kandown`. |
429
- | `getKandownHandle` | Resolves `.kandown` from a remembered project directory handle. |
430
- | `ensureTasksDir` | Ensures `.kandown/tasks` exists. |
431
- | `readConfigFile` / `writeConfigFile` | Load and save `kandown.json`. |
432
- | `listTaskIds` | Scans `.kandown/tasks/*.md` and returns task IDs. |
433
- | `readTaskFile` / `writeTaskFile` / `deleteTaskFile` | Manage per-task markdown files. |
434
- | `saveRecentProject` / `listRecentProjects` / `removeRecentProject` | Manage recent project handles in IndexedDB. |
435
- | `verifyPermission` | Requests read/write permission for a stored handle. |
436
-
437
- ### `src/lib/theme.ts`
438
-
439
- | Function | Description |
364
+ | Font | Stack |
440
365
  |---|---|
441
- | `normalizeThemeMode` | Validates persisted theme mode. |
442
- | `normalizeSkinId` | Validates persisted skin id. |
443
- | `normalizeFontId` | Validates persisted font id. |
444
- | `applyProjectTheme` | Applies resolved light/dark class, dataset metadata, font stack, and CSS variables. |
366
+ | `inter` | Inter-first sans stack |
367
+ | `system` | Native platform sans stack |
368
+ | `serif` | Editorial serif stack |
369
+ | `mono` | Monospace stack |
370
+ | `rounded` | Rounded system stack |
445
371
 
446
- ## CLI Architecture
372
+ ---
447
373
 
448
- The npm CLI lives in `bin/kandown.js`.
374
+ ## 🛠️ Development
449
375
 
450
- | Function | Description |
451
- |---|---|
452
- | `help` | Prints usage and commands. |
453
- | `openInBrowser` | Opens `kandown.html` non-blocking in the system default browser. |
454
- | `findKandownDir` | Locates `.kandown/` walking up from cwd. |
455
- | `copyRecursive` | Copies template directories into `.kandown`. |
456
- | `findAgentsFile` | Detects existing AI-agent instruction files. |
457
- | `appendAgentReference` | Adds a Kandown task-management section to an existing agent file. |
458
- | `createAgentsFileIfMissing` | Creates `AGENTS.md` when no agent instructions exist. |
459
- | `parseArgs` | Parses shared CLI flags such as `--path`, `--force`, and `--port`. |
460
- | `cmdInit` | Installs `.kandown`, templates, config, web app, and agent docs. |
461
- | `cmdUpdate` | Updates `kandown.html` from `dist/index.html`. |
462
- | `createServeServer` | Creates the local HTTP server for the web UI, including placeholder `/api/*` routing. |
463
- | `cmdServe` | Serves `kandown.html` over localhost, injects `window.__KANDOWN_ROOT__`, opens the browser, and launches the board TUI. |
464
- | `cmdTui` | Launches a named TUI screen (`board`, `settings`). |
465
-
466
- The terminal UI source lives under `src/cli/` and is bundled into `bin/tui.js` by `tsup`.
467
-
468
- | File | Description |
469
- |---|---|
470
- | `src/cli/tui.tsx` | Ink entrypoint — alternate screen buffer, renders the `App` shell. |
471
- | `src/cli/app.tsx` | TUI screen router (`board`, `settings`). |
472
- | `src/cli/lib/config.ts` | Node-side `kandown.json` reader/writer with dot-path accessors. |
473
- | `src/cli/lib/board-reader.ts` | Node fs wrapper around task scanning: `readBoard`, `readTask`, `readAgentDoc`, `moveTaskToColumn`. |
474
- | `src/cli/lib/agents.ts` | Agent registry (claude, codex, gemini, goose, aider, opencode), detection via `which`, `buildPrompt`. |
475
- | `src/cli/lib/launcher.ts` | Process spawning: tmux split-pane or direct exec, auto-moves task to In Progress. |
476
- | `src/cli/screens/board.tsx` | Interactive kanban board TUI — column navigation, task detail, agent picker integration. |
477
- | `src/cli/screens/agent-picker.tsx` | Agent selection overlay component. |
478
- | `src/cli/screens/settings.tsx` | Terminal settings editor for `kandown.json`. |
479
-
480
- ## Development
376
+ ### Setup
481
377
 
482
378
  ```bash
379
+ git clone https://github.com/vava-nessa/kandown.git
380
+ cd kandown
483
381
  pnpm install
484
- pnpm dev # web UI (Vite dev server)
485
- pnpm build # full build: web + CLI
382
+ pnpm dev # Web UI at localhost:5173
486
383
  ```
487
384
 
488
385
  ### Scripts
489
386
 
490
387
  | Script | Description |
491
388
  |---|---|
492
- | `pnpm dev` | Start the Vite dev server for the web UI at `localhost:5173`. |
493
- | `pnpm dev:cli` | Watch-mode build for the CLI TUI — rebuilds `bin/tui.js` on every file change. |
494
- | `pnpm build` | Full build: typecheck → Vite web app → CLI TUI bundle. |
495
- | `pnpm build:cli` | Build `src/cli/tui.tsx` into `bin/tui.js` (one-shot). |
496
- | `pnpm preview` | Preview the Vite production build. |
497
- | `pnpm typecheck` | Run TypeScript compiler without emitting files. |
498
-
499
- ### Developing the Web UI
500
-
501
- ```bash
502
- pnpm dev
503
- # → open http://localhost:5173
504
- # → select any folder that has a .kandown/ directory when prompted
505
- ```
506
-
507
- Hot-reload is fully supported. Changes to `src/` reflect immediately in the browser.
508
-
509
- ### Developing the CLI / Board TUI
510
-
511
- The CLI TUI is built with [Ink](https://github.com/vadimdemedes/ink) (React for terminals) and bundled by `tsup`.
389
+ | `pnpm dev` | Vite dev server for the web UI |
390
+ | `pnpm dev:cli` | Watch-mode build for the CLI TUI |
391
+ | `pnpm build` | Full build: typecheck → web app → CLI TUI |
392
+ | `pnpm build:cli` | One-shot CLI TUI build |
393
+ | `pnpm preview` | Preview the Vite production build |
394
+ | `pnpm typecheck` | TypeScript check without emitting |
512
395
 
513
- **Recommended dev workflow** two terminals:
396
+ ### Dev Workflow (CLI / TUI)
514
397
 
515
398
  ```bash
516
- # Terminal 1: watch-rebuild on every save
399
+ # Terminal 1 watch and rebuild on save
517
400
  pnpm dev:cli
518
401
 
519
- # Terminal 2: run the board TUI after each rebuild
402
+ # Terminal 2 test the board TUI
520
403
  node bin/kandown.js board
521
404
 
522
- # Or run the bare command (local web server + browser + board):
523
- node bin/kandown.js
524
-
525
- # Use a fixed local web UI port:
405
+ # Or full experience (web server + browser + TUI):
526
406
  node bin/kandown.js --port 3000
527
407
  ```
528
408
 
529
- `pnpm dev:cli` watches all files under `src/cli/` and `src/lib/` (shared parsers). After saving a file, re-run `node bin/kandown.js board` in the second terminal to see the updated TUI.
530
-
531
- **Testing a specific screen:**
532
-
533
- ```bash
534
- # Board TUI (new)
535
- node bin/kandown.js board
536
-
537
- # Settings TUI
538
- node bin/kandown.js settings
409
+ ### Build Output
539
410
 
540
- # Init in a temp project to test installation
541
- mkdir /tmp/test-kandown && cd /tmp/test-kandown
542
- node /path/to/kandown/bin/kandown.js init
543
- node /path/to/kandown/bin/kandown.js
544
- ```
411
+ | File | Purpose |
412
+ |---|---|
413
+ | `dist/index.html` | Single-file web app → copied as `kandown.html` |
414
+ | `bin/tui.js` | Bundled Ink TUI → used by `kandown board` and `kandown settings` |
545
415
 
546
- **Iterating on the agent picker** — if you don't have any AI agents installed, the picker will show "No agents found". To test the picker UI regardless, temporarily add a dummy agent to the `AGENTS` array in `src/cli/lib/agents.ts` pointing to a binary that exists (e.g. `echo`).
416
+ ---
547
417
 
548
- **Testing tmux integration:**
418
+ ## 🏗️ Architecture
549
419
 
550
- ```bash
551
- # Start a tmux session if you're not in one
552
- tmux new-session -s dev
420
+ <details>
421
+ <summary>Project structure</summary>
553
422
 
554
- # Then run kandown — the agent picker should offer tmux split-pane launch
555
- node bin/kandown.js board
423
+ ```
424
+ kandown/
425
+ ├── bin/
426
+ │ ├── kandown.js # CLI entrypoint (hand-rolled, no deps)
427
+ │ └── tui.js # Generated TUI bundle (tsup)
428
+ ├── src/
429
+ │ ├── App.tsx # Web app shell + global shortcuts
430
+ │ ├── main.tsx # React/Vite browser entrypoint
431
+ │ ├── cli/ # Ink terminal UI source
432
+ │ │ ├── tui.tsx # Ink entrypoint (alternate screen buffer)
433
+ │ │ ├── app.tsx # TUI screen router
434
+ │ │ ├── lib/ # Node-side helpers (config, board, agents, launcher)
435
+ │ │ └── screens/ # board.tsx, agent-picker.tsx, settings.tsx
436
+ │ ├── components/ # React UI components (web)
437
+ │ ├── hooks/ # React hooks
438
+ │ ├── lib/ # Domain model, parser, serializer, store, theme, i18n
439
+ │ └── styles/ # Tailwind layers + CSS tokens
440
+ ├── templates/ # Files copied by `kandown init`
441
+ ├── dist/index.html # Generated single-file web app
442
+ ├── vite.config.ts
443
+ ├── tsup.config.ts
444
+ └── package.json
556
445
  ```
557
446
 
558
- ### Build Output
559
-
560
- `pnpm build` regenerates:
561
-
562
- - `dist/index.html` — single-file web app, copied into target projects as `kandown.html`.
563
- - `bin/tui.js` — bundled Ink TUI, used by `kandown board` and `kandown settings`.
447
+ </details>
564
448
 
565
- Always build before publishing. The published package is intentionally small and includes only `dist/`, `bin/`, `templates/`, `README.md`, and `LICENSE`.
449
+ <details>
450
+ <summary>Runtime flow</summary>
566
451
 
567
- ## Browser Support
452
+ 1. `main.tsx` mounts the React app
453
+ 2. `App` renders header + board / list / settings / empty state
454
+ 3. User selects a folder → File System Access API (or server mode)
455
+ 4. `filesystem.ts` resolves `.kandown/`, `tasks/`, `kandown.json`
456
+ 5. `store.ts` loads config, applies theme, scans tasks, saves recent project handles
457
+ 6. `parser.ts` converts task markdown into typed data
458
+ 7. Mutations flow back through store → `serializer.ts` → `filesystem.ts`
568
459
 
569
- Kandown requires the File System Access API. Supported browsers:
460
+ </details>
570
461
 
571
- - Chrome
572
- - Edge
573
- - Brave
574
- - Opera
462
+ <details>
463
+ <summary>Key modules</summary>
575
464
 
576
- Unsupported:
465
+ | Module | Role |
466
+ |---|---|
467
+ | `src/lib/store.ts` | Zustand state machine — project loading, board mutations, drawer, search, config |
468
+ | `src/lib/filesystem.ts` | File System Access API + IndexedDB helpers |
469
+ | `src/lib/parser.ts` | Markdown parsing, subtask extraction, content search |
470
+ | `src/lib/serializer.ts` | Markdown serialization |
471
+ | `src/lib/theme.ts` | Theme engine — skins, fonts, CSS variables |
472
+ | `src/lib/watcher.ts` | 500ms polling watcher with SHA-256 hashing for external change detection |
473
+ | `src/lib/i18n/index.ts` | 48-locale translation system |
577
474
 
578
- - Firefox
579
- - Safari
475
+ </details>
580
476
 
581
- ## Design Constraints
477
+ ---
582
478
 
583
- - The app must remain local-first and backend-free.
584
- - Markdown stays the canonical data format.
585
- - Task files are the canonical source of truth.
586
- - Board columns belong in `kandown.json`.
587
- - UI state that is project-specific belongs in `kandown.json`.
588
- - Browser-only convenience state, such as recent handles, can live in IndexedDB.
589
- - The installed web app should remain a single file.
479
+ ## 📐 Design Constraints
590
480
 
591
- ## Publishing
481
+ - **Local-first, backend-free** — no servers, no sync vendors
482
+ - **Markdown is the canonical data format** — always human-readable
483
+ - **Task files are the single source of truth** — no index files to sync
484
+ - **Single-file web app** — `kandown.html` bundles everything
485
+ - **Project config in `kandown.json`** — columns, appearance, fields, notifications
486
+ - **Browser state in IndexedDB** — only for convenience (recent project handles)
592
487
 
593
- ```bash
594
- pnpm build
595
- npm login
596
- npm publish --access public
597
- ```
488
+ ---
598
489
 
599
- ## License
490
+ ## 📜 License
600
491
 
601
- MIT
492
+ [MIT](LICENSE) — use it, fork it, ship it.
package/bin/kandown.js CHANGED
@@ -57,6 +57,8 @@ const PKG_ROOT = resolve(__dirname, '..');
57
57
  // 📖 Default localhost range for the zero-config `kandown` web UI server.
58
58
  const DEFAULT_SERVE_PORT = 2048;
59
59
  const MAX_SERVE_PORT = 2060;
60
+ const START_PORT_RANGE = 2048;
61
+ const END_PORT_RANGE = 2060;
60
62
 
61
63
  // 📖 Get current CLI version from package.json at PKG_ROOT
62
64
  function getCurrentVersion() {
@@ -91,7 +93,16 @@ async function checkForUpdate(argv = process.argv) {
91
93
  })).trim().replace(/^"|"$/g, '');
92
94
  if (newVersion === latest) {
93
95
  log(`${c.green}✓ Updated to v${newVersion}${c.reset} — restarting…`);
94
- const child = spawn(process.argv[0], ['--experimental-vm-modules', ...argv.slice(1)], {
96
+ // Resolve the global bin directory from npm prefix and spawn directly,
97
+ // bypassing npx which may re-resolve the cached old version.
98
+ const npmPrefix = String(execSync('npm config get prefix 2>/dev/null', {
99
+ timeout: 5000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
100
+ })).trim();
101
+ const isMacos = existsSync(join(npmPrefix, 'bin', 'kandown'));
102
+ const kandownBin = isMacos
103
+ ? join(npmPrefix, 'bin', 'kandown')
104
+ : join(npmPrefix, 'bin', 'kandown');
105
+ const child = spawn(kandownBin, ['--experimental-vm-modules', ...argv.slice(1)], {
95
106
  detached: true, stdio: 'ignore', env: { ...process.env } });
96
107
  child.unref();
97
108
  process.exit(0);
@@ -617,8 +628,8 @@ function listen(server, port) {
617
628
  }
618
629
 
619
630
  async function listenOnAvailablePort(kandownDir, preferredPort) {
620
- const startPort = preferredPort ?? DEFAULT_SERVE_PORT;
621
- const endPort = preferredPort ?? MAX_SERVE_PORT;
631
+ const startPort = preferredPort ?? START_PORT_RANGE;
632
+ const endPort = preferredPort ?? END_PORT_RANGE;
622
633
 
623
634
  for (let port = startPort; port <= endPort; port++) {
624
635
  const server = createServeServer(kandownDir);
@@ -631,7 +642,7 @@ async function listenOnAvailablePort(kandownDir, preferredPort) {
631
642
  }
632
643
 
633
644
  const range = preferredPort === null
634
- ? `${DEFAULT_SERVE_PORT}-${MAX_SERVE_PORT}`
645
+ ? `${START_PORT_RANGE}-${END_PORT_RANGE}`
635
646
  : String(preferredPort);
636
647
  err(`No free port available in ${c.bold}${range}${c.reset}.`);
637
648
  process.exit(1);
@@ -681,17 +692,27 @@ async function cmdServe(rawArgs) {
681
692
  }
682
693
 
683
694
  /**
684
- * 📖 Opens a file path in the system default browser/app.
695
+ * 📖 Opens a URL in the system default browser after confirming the server is ready.
685
696
  * Non-blocking — spawns the opener and returns immediately.
686
697
  * macOS: open, Linux: xdg-open, Windows: start (via cmd.exe).
687
698
  */
688
- function openInBrowser(filePath) {
699
+ async function openInBrowser(url) {
700
+ // 📖 Wait for server to be truly ready (up to 2s) before opening browser.
701
+ // This prevents ERR_UNSAFE_PORT and similar race conditions.
702
+ for (let i = 0; i < 10; i++) {
703
+ try {
704
+ const res = await fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(500) });
705
+ if (res.ok || res.status === 404) break; // server is up (404 means serving HTML)
706
+ } catch { /* server not ready yet */ }
707
+ await new Promise(r => setTimeout(r, 200));
708
+ }
709
+
689
710
  const opener = process.platform === 'darwin'
690
711
  ? 'open'
691
712
  : process.platform === 'win32'
692
713
  ? 'cmd'
693
714
  : 'xdg-open';
694
- const args = process.platform === 'win32' ? ['/c', 'start', '', filePath] : [filePath];
715
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
695
716
  const child = spawn(opener, args, { detached: true, stdio: 'ignore' });
696
717
  child.on('error', (e) => warn(`Could not open browser automatically: ${e.message}`));
697
718
  child.unref();
package/dist/index.html CHANGED
@@ -112,7 +112,7 @@ Error generating stack: `+g.message+`
112
112
 
113
113
  ## Subtasks
114
114
 
115
- `}}}async function Hu(t,e,n,i){if(fr())return lV(e,mA(n,i));const r=await(await t.getFileHandle(`${e}.md`,{create:!0})).createWritable();await r.write(mA(n,i)),await r.close()}async function bA(t,e){if(fr())return uV(e);try{await t.removeEntry(`${e}.md`)}catch{}}const mV="kanban-md",gV=1,uc="recentProjects";function OR(){return new Promise((t,e)=>{const n=indexedDB.open(mV,gV);n.onerror=()=>e(n.error),n.onsuccess=()=>t(n.result),n.onupgradeneeded=()=>{const i=n.result;i.objectStoreNames.contains(uc)||i.createObjectStore(uc,{keyPath:"id"})}})}async function D0(t){const e=await OR();return new Promise((n,i)=>{const a=e.transaction(uc,"readwrite");a.objectStore(uc).put(t),a.oncomplete=()=>n(),a.onerror=()=>i(a.error)})}async function wk(){const t=await OR();return new Promise((e,n)=>{const a=t.transaction(uc,"readonly").objectStore(uc).getAll();a.onsuccess=()=>{const r=a.result||[];r.sort((o,u)=>u.lastOpened-o.lastOpened),e(r.slice(0,10))},a.onerror=()=>n(a.error)})}async function kA(t,e=!0){const n={mode:e?"readwrite":"read"};return await t.queryPermission(n)==="granted"||await t.requestPermission(n)==="granted"}class vV{dirHandle=null;tasksDirHandle=null;intervalId=null;configHash=null;taskHashes=new Map;knownTaskIds=new Set;listeners=new Map;debounceTimers=new Map;debounceDelay=200;start(e,n){this.dirHandle=e,this.tasksDirHandle=n,this.initHashes(),this.intervalId=setInterval(()=>void this.tick(),500)}stop(){this.intervalId&&(clearInterval(this.intervalId),this.intervalId=null),this.configHash=null,this.taskHashes.clear(),this.knownTaskIds.clear(),this.listeners.clear(),this.debounceTimers.forEach(e=>clearTimeout(e)),this.debounceTimers.clear()}on(e,n){return this.listeners.has(e)||this.listeners.set(e,new Set),this.listeners.get(e).add(n),()=>{this.listeners.get(e)?.delete(n)}}getKnownTaskIds(){return Array.from(this.knownTaskIds)}async initHashes(){if(!this.dirHandle||!this.tasksDirHandle)return;const e=await wA(this.dirHandle);e!==null&&(this.configHash=await this.hash(e)),await this.syncTaskDir(!1)}async tick(){if(!this.dirHandle||!this.tasksDirHandle)return;const e=await wA(this.dirHandle);if(e!==null){const n=await this.hash(e);this.configHash!==null&&n!==this.configHash&&(this.configHash=n,this.debouncedEmit("configChanged"))}for(const n of this.knownTaskIds){const i=await xA(this.tasksDirHandle,n);if(i!==null){const a=await this.hash(i),r=this.taskHashes.get(n);r!==void 0&&a!==r&&(this.taskHashes.set(n,a),this.debouncedEmit("taskChanged",n))}}await this.syncTaskDir(!0)}debouncedEmit(e,...n){const i=e+JSON.stringify(n),a=this.debounceTimers.get(i);a&&clearTimeout(a);const r=setTimeout(()=>{this.debounceTimers.delete(i),this.emit(e,...n)},this.debounceDelay);this.debounceTimers.set(i,r)}async syncTaskDir(e){if(this.tasksDirHandle){for await(const n of this.tasksDirHandle.values())if(n.kind==="file"&&n.name.endsWith(".md")){const i=n.name.replace(".md","");if(!this.knownTaskIds.has(i)){this.knownTaskIds.add(i);const a=await xA(this.tasksDirHandle,i);a!==null&&(this.taskHashes.set(i,await this.hash(a)),e&&this.debouncedEmit("newTaskDetected",i))}}}}async hash(e){const i=new TextEncoder().encode(e),a=await crypto.subtle.digest("SHA-256",i);return Array.from(new Uint8Array(a)).map(o=>o.toString(16).padStart(2,"0")).join("")}emit(e,...n){this.listeners.get(e)?.forEach(a=>{if(e==="configChanged"){a();return}if(e==="taskChanged"){const[o]=n;a(o);return}const[r]=n;a(r)})}}async function wA(t){try{return await(await(await t.getFileHandle("kandown.json")).getFile()).text()}catch{return null}}async function xA(t,e){try{return await(await(await t.getFileHandle(`${e}.md`)).getFile()).text()}catch{return null}}const $u=new vV,yV={soft:[{frequency:520,durationMs:90,delayMs:0,type:"sine"},{frequency:720,durationMs:120,delayMs:95,type:"sine"}],chime:[{frequency:660,durationMs:120,delayMs:0,type:"triangle"},{frequency:880,durationMs:160,delayMs:125,type:"triangle"}],ping:[{frequency:920,durationMs:110,delayMs:0,type:"sine"}],pop:[{frequency:260,durationMs:55,delayMs:0,type:"square"},{frequency:520,durationMs:80,delayMs:60,type:"square"}]};let _A=null;function NR(){return"Notification"in window?Notification.permission:"unsupported"}async function bV(){return"Notification"in window?await Notification.requestPermission():"unsupported"}function C0({title:t,body:e,config:n}){if(n.notifications.sound&&kV(n.notifications.soundId),!(!n.notifications.browser||NR()!=="granted"))try{new Notification(t,{body:e})}catch{}}function kV(t){const e=yV[t],n=window.AudioContext??window.webkitAudioContext;if(!(!n||e.length===0))try{_A??=new n;const i=_A;i.state==="suspended"&&i.resume();const a=i.currentTime+.01;e.forEach(r=>{const o=i.createOscillator(),u=i.createGain(),c=a+r.delayMs/1e3,d=c+r.durationMs/1e3;o.type=r.type??"sine",o.frequency.setValueAtTime(r.frequency,c),u.gain.setValueAtTime(1e-4,c),u.gain.exponentialRampToValueAtTime(.08,c+.01),u.gain.exponentialRampToValueAtTime(1e-4,d),o.connect(u),u.connect(i.destination),o.start(c),o.stop(d+.02)})}catch{}}function wV(t){let e=-1;for(const n of t)for(const i of n.tasks){const a=i.id.match(/^t(\d+)$/);if(a){const r=parseInt(a[1],10);r>e&&(e=r)}}return"t"+(e+1)}async function xV(t){const e=await pV(t);return await Promise.all(e.map(async i=>{const{frontmatter:a,body:r}=await ks(t,i),o={...a,id:a.id||i,status:a.status||"Backlog"},{subtasks:u,bodyWithoutSubtasks:c}=bs(r);return{id:i,frontmatter:o,body:c,subtasks:u}}))}async function SA(t,e,n){const i=[];for(const a of e){const r=a.name;a.tasks.forEach((o,u)=>{i.push((async()=>{const{frontmatter:c,body:d}=await ks(t,o.id);await Hu(t,o.id,{...c,id:o.id,status:r,order:u},d)})())})}await Promise.all(i)}function yl(t){iV(t.ui.theme,t.ui.skin,t.ui.font,t.ui.background)}function xk(t){return{title:t.frontmatter.title||t.id,status:t.frontmatter.status||"Backlog",body:t.body,subtasks:t.subtasks}}function DA(t){Zu.clear(),t.forEach(e=>{Zu.set(e.id,xk(e))})}function _V(t,e){return e.reduce((n,i,a)=>{const r=t[a]?.done??!1;return n+(i.done&&!r?1:0)},0)}function SV(t,e){const n=t.subtasks.map(a=>({text:a.text,description:a.description??"",report:a.report??""})),i=e.subtasks.map(a=>({text:a.text,description:a.description??"",report:a.report??""}));return t.title!==e.title||t.body!==e.body||JSON.stringify(n)!==JSON.stringify(i)}let DV=0;const Zu=new Map,Od=new Map,ze=WU((t,e)=>({isOpen:!1,loading:!1,dirHandle:null,projectName:null,tasksDirHandle:null,boardTitle:"Project Kanban",columns:[],taskContents:new Map,searchMatches:new Map,viewMode:localStorage.getItem("kandown:view")||"board",density:localStorage.getItem("kandown:density")||"comfortable",filters:{search:"",priority:null,tag:null,assignee:null,ownerType:null},commandOpen:!1,drawerTaskId:null,drawerData:null,currentPage:"board",config:La,recentProjects:[],toasts:[],drawerBaseVersion:null,conflictState:null,showConflictModal:!1,openFolder:async()=>{const n=await dV();if(!n)return;const{projectHandle:i,kandownHandle:a}=n,r=await uf(a),o=i.name;t({dirHandle:a,tasksDirHandle:r,projectName:o}),window.history.pushState({},"",`?p=${encodeURIComponent(o)}`);const u=fr()?S0():null;await D0({id:i.name,name:i.name,handle:i,lastOpened:Date.now(),...u?{kandownDir:u}:{}}),await e().loadConfig(),await e().reloadBoard();const c=await wk();t({recentProjects:c}),e().setupWatcher()},openRecentProject:async n=>{if(!await kA(n.handle,!0)){e().toast("Permission denied","error");return}const a=await yA(n.handle),r=await uf(a),o=n.handle.name;t({dirHandle:a,tasksDirHandle:r,projectName:o}),window.history.pushState({},"",`?p=${encodeURIComponent(o)}`),await D0({...n,lastOpened:Date.now()}),await e().loadConfig(),await e().reloadBoard(),e().setupWatcher()},openServerProject:async()=>{t({loading:!0});try{const n=S0();if(!n)throw new Error("No server root");const i=n.split("/").filter(Boolean).pop()??"Project",a=await FR();yl(a);const r=await BR(),o=await Promise.all(r.map(async p=>{const{frontmatter:m,body:v}=await oV(p),y={...m,id:m.id||p,status:m.status||"Backlog"},{subtasks:_,bodyWithoutSubtasks:w}=bs(v);return{id:p,frontmatter:y,body:w,subtasks:_}}));DA(o);const u=o.map(p=>({frontmatter:p.frontmatter,body:Fd(p.body,p.subtasks)})),c=vA(u,a.board.columns),d=c.reduce((p,m)=>p+m.tasks.length,0),h=new Map;if(d<=10)for(const p of o)h.set(p.frontmatter.id,{frontmatter:p.frontmatter,subtasks:p.subtasks,body:p.body});t({loading:!1,isOpen:!0,config:a,columns:c,boardTitle:"Project Kanban",projectName:i,taskContents:h,searchMatches:new Map}),window.history.pushState({},"",`?p=${encodeURIComponent(i)}`)}catch{t({loading:!1,isOpen:!1}),e().toast("Impossible de charger le projet. Relancez `kandown`.","error")}},tryAutoOpenServerProject:async()=>{if(!fr())return;const n=S0();if(!n)return;const i=await wk(),a=i.find(d=>d.kandownDir===n);if(!a){await e().openServerProject();return}if(!await kA(a.handle,!0)){await e().openServerProject();return}const o=await yA(a.handle),u=await uf(o),c=a.handle.name;t({dirHandle:o,tasksDirHandle:u,projectName:c,recentProjects:i}),window.history.pushState({},"",`?p=${encodeURIComponent(c)}`),await D0({...a,lastOpened:Date.now()}),await e().loadConfig(),await e().reloadBoard(),e().setupWatcher()},loadConfig:async()=>{const{dirHandle:n}=e();if(n)try{const i=await fV(n);i?(t({config:i}),yl(i)):(t({config:La}),yl(La))}catch{t({config:La}),yl(La)}},updateConfig:async n=>{const{dirHandle:i,config:a}=e();if(!i)return;const r=n(a);t({config:r}),yl(r);try{await hV(i,r)}catch(o){e().toast("Failed to save config: "+o.message,"error")}},reloadBoard:async()=>{const{tasksDirHandle:n,config:i}=e();if(n)try{const a=await xV(n);DA(a);const r=a.map(d=>({frontmatter:d.frontmatter,body:Fd(d.body,d.subtasks)})),o=vA(r,i.board.columns);t({boardTitle:"Project Kanban",columns:o});const u=o.reduce((d,h)=>d+h.tasks.length,0),c=new Map;if(u<=10)for(const d of a)c.set(d.frontmatter.id,{frontmatter:d.frontmatter,subtasks:d.subtasks,body:d.body});t({taskContents:c,searchMatches:new Map})}catch(a){e().toast("Failed to load board: "+a.message,"error")}},moveTask:async(n,i,a,r)=>{const{columns:o,tasksDirHandle:u,config:c}=e();if(!u)return;const d=o.find(w=>w.name===i),h=o.find(w=>w.name===a);if(!d||!h)return;const p=d.tasks.findIndex(w=>w.id===n);if(p===-1)return;const m=o.map(w=>({...w,tasks:[...w.tasks]})),v=m.find(w=>w.name===i),y=m.find(w=>w.name===a),[_]=v.tasks.splice(p,1);/done|termin|closed|complet/i.test(a)?_.checked=!0:_.checked=!1,r!==void 0?y.tasks.splice(r,0,_):y.tasks.push(_),t({columns:m});try{const w=i===a?m.filter(b=>b.name===a):m.filter(b=>b.name===i||b.name===a);await SA(u,w,c.board.columns)}catch(w){e().toast("Failed to save: "+w.message,"error"),t({columns:o})}},reorderInColumn:async(n,i,a)=>{const{columns:r,tasksDirHandle:o,config:u}=e();if(!o)return;const c=r.map(p=>({...p,tasks:[...p.tasks]})),d=c.find(p=>p.name===n);if(!d)return;const[h]=d.tasks.splice(i,1);d.tasks.splice(a,0,h),t({columns:c});try{await SA(o,[d],u.board.columns)}catch(p){e().toast("Failed to save: "+p.message,"error"),t({columns:r})}},addColumn:async n=>{const i=n.trim();if(!i)return;const{config:a}=e();a.board.columns.some(r=>r.toLowerCase()===i.toLowerCase())||(await e().updateConfig(r=>({...r,board:{...r.board,columns:[...r.board.columns,i]}})),await e().reloadBoard())},renameColumn:async(n,i)=>{const a=i.trim(),{columns:r,tasksDirHandle:o,config:u}=e();if(!o||!a||a.toLowerCase()===n.toLowerCase())return;if(r.some(h=>h.name.toLowerCase()===a.toLowerCase())){e().toast("Column already exists","error");return}const c=r,d=r.map(h=>h.name===n?{...h,name:a}:h);t({columns:d});try{const h=c.find(p=>p.name===n);h&&await Promise.all(h.tasks.map(async(p,m)=>{const{frontmatter:v,body:y}=await ks(o,p.id);await Hu(o,p.id,{...v,id:p.id,status:a,order:m},y)})),await e().updateConfig(p=>{const m={...p.board.columnColors??{}},v=m[n.toLowerCase()];v&&(m[a.toLowerCase()]=v,delete m[n.toLowerCase()]);const y=p.board.columns.some(_=>_.toLowerCase()===n.toLowerCase())?p.board.columns:[...p.board.columns,n];return{...p,board:{...p.board,columns:y.map(_=>_.toLowerCase()===n.toLowerCase()?a:_),columnColors:m}}}),await e().reloadBoard()}catch(h){e().toast("Failed to rename column: "+h.message,"error"),t({columns:c})}},deleteColumn:async n=>{const{columns:i,tasksDirHandle:a}=e();if(!a)return;const r=i.find(u=>u.name===n);if(!r)return;const o=i;t({columns:i.filter(u=>u.name!==n)});try{await Promise.all(r.tasks.map(u=>bA(a,u.id))),await e().updateConfig(u=>{const c={...u.board.columnColors??{}};return delete c[n.toLowerCase()],{...u,board:{...u.board,columns:u.board.columns.filter(d=>d.toLowerCase()!==n.toLowerCase()),columnColors:c}}}),await e().reloadBoard(),e().toast("Column deleted")}catch(u){e().toast("Failed to delete column: "+u.message,"error"),t({columns:o})}},createTask:async n=>{const{columns:i,tasksDirHandle:a,config:r,taskContents:o}=e();if(!a||!i.length)return null;const u=n||r.board.columns[0]||i[0].name,c=wV(i),d=i.find(m=>m.name===u)?.tasks.length??0,h={id:c,title:"",checked:!1,tags:[],assignee:null,priority:r.fields.priority?r.board.defaultPriority:null,ownerType:r.fields.ownerType?r.board.defaultOwnerType:"",progress:null},p=i.map(m=>m.name===u?{...m,tasks:[...m.tasks,h]}:m);t({columns:p});try{const m={id:c,title:"",status:u,order:d,priority:r.fields.priority?r.board.defaultPriority:"",tags:[],assignee:"",created:new Date().toISOString().slice(0,10),ownerType:r.fields.ownerType?r.board.defaultOwnerType:"",tools:""},v="";await Hu(a,c,m,v);const y=new Map(o);return y.set(c,{frontmatter:m,subtasks:[],body:v}),t({taskContents:y}),e().toast(`Created ${c.replace(/^t/,"")}`),await e().openDrawer(c),c}catch(m){return e().toast("Failed to create: "+m.message,"error"),t({columns:i}),null}},deleteTask:async n=>{const{columns:i,tasksDirHandle:a,taskContents:r}=e();if(!a)return;const o=i.map(d=>({...d,tasks:d.tasks.filter(h=>h.id!==n)}));t({columns:o});const u=new Map(r);u.delete(n);const c=new Map(e().searchMatches);c.delete(n),t({taskContents:u,searchMatches:c});try{await bA(a,n),e().toast("Deleted")}catch(d){e().toast("Failed to delete: "+d.message,"error"),t({columns:i})}},openDrawer:async n=>{const{tasksDirHandle:i}=e();if(i)try{const{frontmatter:a,body:r}=await ks(i,n),{subtasks:o,bodyWithoutSubtasks:u}=bs(r),c={frontmatter:a,subtasks:o,body:u,savedAt:Date.now()};t({drawerTaskId:n,drawerData:{frontmatter:a,subtasks:o,body:u},drawerBaseVersion:c,conflictState:null,showConflictModal:!1})}catch(a){e().toast("Failed to open: "+a.message,"error")}},closeDrawer:()=>t({drawerTaskId:null,drawerData:null,drawerBaseVersion:null,conflictState:null,showConflictModal:!1}),updateDrawerData:n=>{const{drawerData:i}=e();i&&t({drawerData:n(i)})},saveDrawer:async()=>{const{drawerTaskId:n,drawerData:i,tasksDirHandle:a,taskContents:r}=e();if(!n||!i||!a)return;const o=Fd(i.body,i.subtasks),u={...i.frontmatter,id:n};try{await Hu(a,n,u,o),e().toast("Saved"),t({drawerTaskId:null,drawerData:null});const c=new Map(r);c.set(n,{frontmatter:u,subtasks:i.subtasks,body:i.body}),t({taskContents:c}),await e().reloadBoard()}catch(c){e().toast("Failed to save: "+c.message,"error")}},saveDrawerMetadata:async()=>{const{drawerTaskId:n,drawerData:i,tasksDirHandle:a,taskContents:r}=e();if(!n||!i||!a)return;const o=Fd(i.body,i.subtasks),u={...i.frontmatter,id:n};try{await Hu(a,n,u,o);const c=new Map(r);c.set(n,{frontmatter:u,subtasks:i.subtasks,body:i.body}),t({taskContents:c}),await e().reloadBoard()}catch(c){e().toast("Failed to save: "+c.message,"error")}},setViewMode:n=>{localStorage.setItem("kandown:view",n),t({viewMode:n})},setDensity:n=>{localStorage.setItem("kandown:density",n),t({density:n})},setFilter:(n,i)=>{if(t(a=>({filters:{...a.filters,[n]:i}})),n==="search"){const{columns:a,tasksDirHandle:r,taskContents:o}=e(),u=i,c=a.flatMap(d=>d.tasks.map(h=>h.id));if(r){const d=c.filter(h=>!o.has(h));d.length>0?e().loadTaskContents(d).then(()=>{e().computeSearchMatches(u)}):e().computeSearchMatches(u)}}},clearFilters:()=>t({filters:{search:"",priority:null,tag:null,assignee:null,ownerType:null},searchMatches:new Map}),setCommandOpen:n=>t({commandOpen:n}),setCurrentPage:n=>t({currentPage:n}),loadTaskContents:async n=>{const{tasksDirHandle:i}=e();if(!i)return;const a=new Map(e().taskContents);await Promise.all(n.map(async r=>{if(!a.has(r))try{const{frontmatter:o,body:u}=await ks(i,r),{subtasks:c,bodyWithoutSubtasks:d}=bs(u);a.set(r,{frontmatter:o,subtasks:c,body:d})}catch{}})),t({taskContents:a})},computeSearchMatches:n=>{if(!n.trim()){t({searchMatches:new Map});return}const{taskContents:i}=e(),a=new Map,r=n.toLowerCase();for(const[o,u]of i){const c=RR(u,r);c.length>0&&a.set(o,c)}t({searchMatches:a})},toast:(n,i="success")=>{const a=++DV;t(r=>({toasts:[...r.toasts,{id:a,message:n,type:i}]})),setTimeout(()=>{t(r=>({toasts:r.toasts.filter(o=>o.id!==a)}))},2500)},dismissToast:n=>t(i=>({toasts:i.toasts.filter(a=>a.id!==n)})),resolveConflict:async n=>{const{conflictState:i,drawerData:a,tasksDirHandle:r,drawerTaskId:o,drawerBaseVersion:u}=e();if(!(!i||!r||!o))if(n==="reload"){const{frontmatter:c,body:d}=await ks(r,o),{subtasks:h,bodyWithoutSubtasks:p}=bs(d);t({drawerData:{frontmatter:c,subtasks:h,body:p},drawerBaseVersion:{frontmatter:c,subtasks:h,body:p,savedAt:Date.now()},conflictState:null,showConflictModal:!1}),e().toast("Reloaded from disk")}else if(n==="overwrite"){if(a&&o&&u){const c=Fd(a.body,a.subtasks),d={...a.frontmatter,id:o};await Hu(r,o,d,c),t({drawerBaseVersion:{...a,savedAt:Date.now()},conflictState:null,showConflictModal:!1}),e().toast("Overwritten remote changes")}}else t({conflictState:null,showConflictModal:!1})},setupWatcher:()=>{const{dirHandle:n,tasksDirHandle:i}=e();if(!n||!i)return;$u.stop(),Od.forEach(o=>clearTimeout(o)),Od.clear(),$u.start(n,i);const a=(o,u)=>{const c=Od.get(o);c&&clearTimeout(c);const d=Math.max(2e3,e().config.notifications.editDebounceMs),h=setTimeout(()=>{Od.delete(o);const p=e().config;p.notifications.taskEdits&&C0({title:"Task edited",body:`${u} changed on disk.`,config:p})},d);Od.set(o,h)},r=async o=>{const{tasksDirHandle:u,config:c}=e();if(!u)return;const{frontmatter:d,body:h}=await ks(u,o),{subtasks:p,bodyWithoutSubtasks:m}=bs(h),v={id:o,frontmatter:{...d,id:d.id||o,status:d.status||"Backlog"},body:m,subtasks:p},y=xk(v),_=Zu.get(o);if(!_){Zu.set(o,y);return}c.notifications.statusChanges&&_.status!==y.status&&C0({title:"Task status changed",body:`${y.title}: ${_.status} → ${y.status}`,config:c});const w=_V(_.subtasks,y.subtasks);c.notifications.subtaskCompletions&&w>0&&C0({title:"Subtask completed",body:w===1?`${y.title}: 1 subtask completed.`:`${y.title}: ${w} subtasks completed.`,config:c}),SV(_,y)&&a(o,y.title),Zu.set(o,y)};$u.on("configChanged",()=>{e().loadConfig(),e().toast("Settings updated externally","info")}),$u.on("taskChanged",async o=>{const{drawerTaskId:u,drawerBaseVersion:c,tasksDirHandle:d}=e();if(await r(o),u===o&&c&&d){const{frontmatter:h,body:p}=await ks(d,o),{subtasks:m,bodyWithoutSubtasks:v}=bs(p),y=c,_=JSON.stringify(y.frontmatter)!==JSON.stringify(h),w=y.body!==v,b=JSON.stringify(y.subtasks)!==JSON.stringify(m);if(!_&&!w&&!b)return;let C="none";_&&(w||b)?C="full":_?C="metadata-only":(w||b)&&(C="body-only"),t({conflictState:{taskId:o,type:C,local:y,remote:{frontmatter:h,body:v,subtasks:m}},showConflictModal:C==="full"})}else e().reloadBoard()}),$u.on("newTaskDetected",async o=>{const{tasksDirHandle:u}=e();if(u){const{frontmatter:c,body:d}=await ks(u,o),{subtasks:h,bodyWithoutSubtasks:p}=bs(d);Zu.set(o,xk({id:o,frontmatter:{...c,id:c.id||o,status:c.status||"Backlog"},body:p,subtasks:h}))}e().reloadBoard()})}}));wk().then(t=>{ze.setState({recentProjects:t})});yl(La);window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>{yl(ze.getState().config)});function CV(t){const e=i7(t,{stiffness:180,damping:22,mass:.8});return H.useEffect(()=>{e.set(t)},[t,e]),oR(e,i=>Math.round(i).toString())}const _k="0.3.2",AV=({className:t})=>x.jsxs("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 150 150",className:t,fill:"currentColor",children:[x.jsx("path",{d:"m57.6 64.6 0.1-0.1v-31.3c-0.1-3.5-2.7-5.6-5.7-5.6h-16.3v59.6c0.2-1.3 0.9-2.6 1.7-3.2l20.2-19.4z"}),x.jsx("path",{d:"m87.6 43.8c-3.4 0.1-7.1 1.3-9.9 3.7l-38.5 38.7c-2.1 2.1-3.4 4.8-3.5 7.5v26.7l77.5-76.4v-0.2h-25.6z"}),x.jsx("path",{d:"m108.1 96.4-6 5-22.4-20.8-14.6 14.2 21.1 21.4-5 4.1c-0.6 0.5-0.3 0.9 0.2 0.9h27.6c0.4 0 0.7-0.4 0.7-0.6v-24.8c0-0.7-1.1-0.3-1.6 0.3v0.3z"})]});function EV(){const{t}=di(),e=ze(O=>O.dirHandle);ze(O=>O.projectName);const n=ze(O=>O.columns),i=ze(O=>O.openFolder),a=ze(O=>O.reloadBoard),r=ze(O=>O.createTask),o=ze(O=>O.setCommandOpen),u=ze(O=>O.viewMode),c=ze(O=>O.setViewMode),d=ze(O=>O.density),h=ze(O=>O.setDensity),p=ze(O=>O.setCurrentPage),m=ze(O=>O.recentProjects),v=ze(O=>O.openRecentProject),y=ze(O=>O.filters),_=ze(O=>O.setFilter),w=ze(O=>O.clearFilters),b=ze(O=>O.config.fields),[C,D]=H.useState(!1),A=H.useRef(null),M=H.useRef(null),T=n.reduce((O,$)=>O+$.tasks.length,0),F=CV(T),R=[];b.priority&&y.priority&&R.push({type:"priority",label:y.priority,value:y.priority}),b.tags&&y.tag&&R.push({type:"tag",label:"#"+y.tag,value:y.tag}),b.assignee&&y.assignee&&R.push({type:"assignee",label:"@"+y.assignee,value:y.assignee});const P=[{label:t("filterBar.ownerAll"),value:""},{label:t("filterBar.ownerHuman"),value:"human"},{label:t("filterBar.ownerAI"),value:"ai"}],E=R.length>0||y.search||b.ownerType&&y.ownerType;return H.useEffect(()=>{if(!C)return;const O=$=>{A.current&&!A.current.contains($.target)&&D(!1)};return document.addEventListener("mousedown",O),()=>document.removeEventListener("mousedown",O)},[C]),x.jsxs("header",{className:"flex items-center justify-between px-5 h-14 border-b border-border bg-bg relative z-10",children:[x.jsxs("div",{className:"flex items-center gap-3 min-w-0",children:[x.jsx("div",{className:"flex items-center gap-2 flex-shrink-0",children:x.jsxs("button",{onClick:()=>window.history.pushState({},"",window.location.pathname),className:"flex items-center gap-2 cursor-pointer",children:[x.jsx(AV,{className:"w-[36px] h-[36px] dark:text-white text-black"}),x.jsx("span",{className:"text-[15px] font-semibold tracking-tight",children:t("app.name")}),x.jsxs("span",{className:"text-[11px] font-mono font-bold text-red-500",children:["v",_k]})]})}),e&&x.jsxs(x.Fragment,{children:[x.jsx("div",{className:"w-px h-[18px] bg-border flex-shrink-0"}),x.jsxs("div",{className:"flex items-center gap-2 px-2.5 h-8 bg-bg-2 border border-border rounded-[8px] min-w-[180px] max-w-[260px] focus-within:border-border-focus focus-within:bg-bg-3 transition-colors",children:[x.jsx(oi.Search,{size:14,className:"text-fg-muted flex-shrink-0"}),x.jsx("input",{ref:M,type:"text",placeholder:t("filterBar.searchPlaceholder"),value:y.search,onChange:O=>_("search",O.target.value),className:"bg-transparent border-none outline-none text-fg text-[13px] w-full placeholder:text-fg-muted"}),y.search&&x.jsx("button",{onClick:()=>_("search",""),className:"text-fg-muted hover:text-fg flex-shrink-0",children:x.jsx(oi.X,{size:14})})]}),x.jsxs("div",{className:"flex items-center gap-1.5 flex-wrap overflow-hidden",children:[x.jsx(ba,{children:R.map(O=>x.jsxs(Xt.button,{initial:{opacity:0,scale:.9},animate:{opacity:1,scale:1},exit:{opacity:0,scale:.9},transition:{duration:.15},onClick:()=>_(O.type,null),className:"inline-flex items-center gap-1 h-6 px-2 text-[12px] text-fg bg-bg-3 border border-border-strong rounded-[4px] hover:bg-bg-hover transition-colors",children:[O.label,x.jsx(oi.X,{size:10,className:"text-fg-muted"})]},O.type+O.value))}),b.ownerType&&x.jsx("div",{className:"flex items-center h-6 border border-border-strong rounded-[4px] overflow-hidden",children:P.map(O=>x.jsx("button",{onClick:()=>_("ownerType",O.value),className:`h-full px-2 text-[12px] transition-colors ${y.ownerType===O.value?"bg-bg-hover text-fg":"text-fg-muted hover:text-fg"}`,children:O.label},O.value))}),E&&x.jsx("button",{onClick:w,className:"text-[12px] text-fg-muted hover:text-fg transition-colors",children:t("filterBar.clearAll")})]})]}),e&&x.jsxs(x.Fragment,{children:[x.jsx("div",{className:"w-px h-[18px] bg-border flex-shrink-0"}),x.jsxs("div",{className:"relative flex-shrink-0",ref:A,children:[x.jsxs("button",{onClick:()=>D(O=>!O),className:"flex items-center gap-1.5 px-2 py-1 text-[14px] text-fg-dim hover:text-fg hover:bg-bg-2 rounded-[6px] transition-colors",children:[x.jsx(oi.Folder,{size:12,className:"text-fg-muted"}),x.jsx("span",{children:e.name}),x.jsx(oi.ChevronDown,{size:10,className:"opacity-60"})]}),x.jsx(ba,{children:C&&x.jsx(Xt.div,{initial:{opacity:0,y:-4,scale:.98},animate:{opacity:1,y:0,scale:1},exit:{opacity:0,y:-4,scale:.98},transition:{duration:.12},className:"absolute top-full left-0 mt-1.5 min-w-[240px] glass rounded-[8px] shadow-[0_16px_48px_rgba(0,0,0,0.5)] overflow-hidden z-50",children:x.jsxs("div",{className:"py-1.5",children:[m.length>0&&x.jsxs(x.Fragment,{children:[x.jsx("div",{className:"px-3 py-1 text-[11px] font-semibold uppercase tracking-wider text-fg-faint",children:t("header.recentProjects")}),m.map(O=>x.jsxs("button",{onClick:()=>{D(!1),v(O)},className:"w-full flex items-center gap-2 px-3 py-1.5 text-[13.5px] text-left hover:bg-bg-3 transition-colors",children:[x.jsx(oi.Folder,{size:12,className:"text-fg-muted"}),x.jsx("span",{className:"truncate",children:O.name}),O.id===e.name&&x.jsx(oi.Check,{size:12,className:"ml-auto text-success"})]},O.id)),x.jsx("div",{className:"h-px bg-border my-1.5 mx-2"})]}),x.jsxs("button",{onClick:()=>{D(!1),i()},className:"w-full flex items-center gap-2 px-3 py-1.5 text-[13.5px] text-left hover:bg-bg-3 transition-colors",children:[x.jsx(oi.Plus,{size:12,className:"text-fg-muted"}),x.jsx("span",{children:t("header.openFolder...")})]})]})})})]})]})]}),x.jsx("div",{className:"flex items-center gap-1.5 flex-shrink-0",children:e?x.jsxs(x.Fragment,{children:[x.jsxs("div",{className:"flex items-center gap-2 mr-2 text-[13px] text-fg-muted",children:[x.jsx("span",{className:"inline-block w-1.5 h-1.5 rounded-full bg-success"}),x.jsx(Xt.span,{className:"tabular-nums",children:F}),x.jsx("span",{children:t("header.tasks")})]}),x.jsxs("div",{className:"flex items-center bg-bg-2 border border-border rounded-[10px] p-0.5 h-11",children:[x.jsx("button",{onClick:()=>c("board"),className:`w-10 h-10 inline-flex items-center justify-center rounded-[8px] transition-all ${u==="board"?"bg-bg-3 text-fg":"text-fg-muted hover:text-fg"}`,title:t("common.board"),children:x.jsx(oi.LayoutBoard,{size:20})}),x.jsx("button",{onClick:()=>c("list"),className:`w-10 h-10 inline-flex items-center justify-center rounded-[8px] transition-all ${u==="list"?"bg-bg-3 text-fg":"text-fg-muted hover:text-fg"}`,title:t("common.list"),children:x.jsx(oi.LayoutList,{size:20})})]}),x.jsx(Qi,{variant:"icon",icon:"Density",onClick:()=>h(d==="compact"?"comfortable":"compact"),title:`Density: ${d}`}),x.jsx(Qi,{variant:"icon",icon:"Settings",onClick:()=>p("settings"),title:t("common.settings")}),x.jsx(Qi,{variant:"secondary",icon:"Search",label:t("common.search"),shortcut:"⌘K",onClick:()=>o(!0),title:"Command palette (⌘K)"}),x.jsx(Qi,{variant:"icon",icon:"Refresh",onClick:a,title:`${t("common.reload")} (R)`}),x.jsx(Qi,{variant:"primary",icon:"Plus",label:t("common.newTask"),shortcut:"N",onClick:()=>r()})]}):x.jsx(Qi,{variant:"primary",label:t("common.openFolder"),onClick:i})})]})}/**
115
+ `}}}async function Hu(t,e,n,i){if(fr())return lV(e,mA(n,i));const r=await(await t.getFileHandle(`${e}.md`,{create:!0})).createWritable();await r.write(mA(n,i)),await r.close()}async function bA(t,e){if(fr())return uV(e);try{await t.removeEntry(`${e}.md`)}catch{}}const mV="kanban-md",gV=1,uc="recentProjects";function OR(){return new Promise((t,e)=>{const n=indexedDB.open(mV,gV);n.onerror=()=>e(n.error),n.onsuccess=()=>t(n.result),n.onupgradeneeded=()=>{const i=n.result;i.objectStoreNames.contains(uc)||i.createObjectStore(uc,{keyPath:"id"})}})}async function D0(t){const e=await OR();return new Promise((n,i)=>{const a=e.transaction(uc,"readwrite");a.objectStore(uc).put(t),a.oncomplete=()=>n(),a.onerror=()=>i(a.error)})}async function wk(){const t=await OR();return new Promise((e,n)=>{const a=t.transaction(uc,"readonly").objectStore(uc).getAll();a.onsuccess=()=>{const r=a.result||[];r.sort((o,u)=>u.lastOpened-o.lastOpened),e(r.slice(0,10))},a.onerror=()=>n(a.error)})}async function kA(t,e=!0){const n={mode:e?"readwrite":"read"};return await t.queryPermission(n)==="granted"||await t.requestPermission(n)==="granted"}class vV{dirHandle=null;tasksDirHandle=null;intervalId=null;configHash=null;taskHashes=new Map;knownTaskIds=new Set;listeners=new Map;debounceTimers=new Map;debounceDelay=200;start(e,n){this.dirHandle=e,this.tasksDirHandle=n,this.initHashes(),this.intervalId=setInterval(()=>void this.tick(),500)}stop(){this.intervalId&&(clearInterval(this.intervalId),this.intervalId=null),this.configHash=null,this.taskHashes.clear(),this.knownTaskIds.clear(),this.listeners.clear(),this.debounceTimers.forEach(e=>clearTimeout(e)),this.debounceTimers.clear()}on(e,n){return this.listeners.has(e)||this.listeners.set(e,new Set),this.listeners.get(e).add(n),()=>{this.listeners.get(e)?.delete(n)}}getKnownTaskIds(){return Array.from(this.knownTaskIds)}async initHashes(){if(!this.dirHandle||!this.tasksDirHandle)return;const e=await wA(this.dirHandle);e!==null&&(this.configHash=await this.hash(e)),await this.syncTaskDir(!1)}async tick(){if(!this.dirHandle||!this.tasksDirHandle)return;const e=await wA(this.dirHandle);if(e!==null){const n=await this.hash(e);this.configHash!==null&&n!==this.configHash&&(this.configHash=n,this.debouncedEmit("configChanged"))}for(const n of this.knownTaskIds){const i=await xA(this.tasksDirHandle,n);if(i!==null){const a=await this.hash(i),r=this.taskHashes.get(n);r!==void 0&&a!==r&&(this.taskHashes.set(n,a),this.debouncedEmit("taskChanged",n))}}await this.syncTaskDir(!0)}debouncedEmit(e,...n){const i=e+JSON.stringify(n),a=this.debounceTimers.get(i);a&&clearTimeout(a);const r=setTimeout(()=>{this.debounceTimers.delete(i),this.emit(e,...n)},this.debounceDelay);this.debounceTimers.set(i,r)}async syncTaskDir(e){if(this.tasksDirHandle){for await(const n of this.tasksDirHandle.values())if(n.kind==="file"&&n.name.endsWith(".md")){const i=n.name.replace(".md","");if(!this.knownTaskIds.has(i)){this.knownTaskIds.add(i);const a=await xA(this.tasksDirHandle,i);a!==null&&(this.taskHashes.set(i,await this.hash(a)),e&&this.debouncedEmit("newTaskDetected",i))}}}}async hash(e){const i=new TextEncoder().encode(e),a=await crypto.subtle.digest("SHA-256",i);return Array.from(new Uint8Array(a)).map(o=>o.toString(16).padStart(2,"0")).join("")}emit(e,...n){this.listeners.get(e)?.forEach(a=>{if(e==="configChanged"){a();return}if(e==="taskChanged"){const[o]=n;a(o);return}const[r]=n;a(r)})}}async function wA(t){try{return await(await(await t.getFileHandle("kandown.json")).getFile()).text()}catch{return null}}async function xA(t,e){try{return await(await(await t.getFileHandle(`${e}.md`)).getFile()).text()}catch{return null}}const $u=new vV,yV={soft:[{frequency:520,durationMs:90,delayMs:0,type:"sine"},{frequency:720,durationMs:120,delayMs:95,type:"sine"}],chime:[{frequency:660,durationMs:120,delayMs:0,type:"triangle"},{frequency:880,durationMs:160,delayMs:125,type:"triangle"}],ping:[{frequency:920,durationMs:110,delayMs:0,type:"sine"}],pop:[{frequency:260,durationMs:55,delayMs:0,type:"square"},{frequency:520,durationMs:80,delayMs:60,type:"square"}]};let _A=null;function NR(){return"Notification"in window?Notification.permission:"unsupported"}async function bV(){return"Notification"in window?await Notification.requestPermission():"unsupported"}function C0({title:t,body:e,config:n}){if(n.notifications.sound&&kV(n.notifications.soundId),!(!n.notifications.browser||NR()!=="granted"))try{new Notification(t,{body:e})}catch{}}function kV(t){const e=yV[t],n=window.AudioContext??window.webkitAudioContext;if(!(!n||e.length===0))try{_A??=new n;const i=_A;i.state==="suspended"&&i.resume();const a=i.currentTime+.01;e.forEach(r=>{const o=i.createOscillator(),u=i.createGain(),c=a+r.delayMs/1e3,d=c+r.durationMs/1e3;o.type=r.type??"sine",o.frequency.setValueAtTime(r.frequency,c),u.gain.setValueAtTime(1e-4,c),u.gain.exponentialRampToValueAtTime(.08,c+.01),u.gain.exponentialRampToValueAtTime(1e-4,d),o.connect(u),u.connect(i.destination),o.start(c),o.stop(d+.02)})}catch{}}function wV(t){let e=-1;for(const n of t)for(const i of n.tasks){const a=i.id.match(/^t(\d+)$/);if(a){const r=parseInt(a[1],10);r>e&&(e=r)}}return"t"+(e+1)}async function xV(t){const e=await pV(t);return await Promise.all(e.map(async i=>{const{frontmatter:a,body:r}=await ks(t,i),o={...a,id:a.id||i,status:a.status||"Backlog"},{subtasks:u,bodyWithoutSubtasks:c}=bs(r);return{id:i,frontmatter:o,body:c,subtasks:u}}))}async function SA(t,e,n){const i=[];for(const a of e){const r=a.name;a.tasks.forEach((o,u)=>{i.push((async()=>{const{frontmatter:c,body:d}=await ks(t,o.id);await Hu(t,o.id,{...c,id:o.id,status:r,order:u},d)})())})}await Promise.all(i)}function yl(t){iV(t.ui.theme,t.ui.skin,t.ui.font,t.ui.background)}function xk(t){return{title:t.frontmatter.title||t.id,status:t.frontmatter.status||"Backlog",body:t.body,subtasks:t.subtasks}}function DA(t){Zu.clear(),t.forEach(e=>{Zu.set(e.id,xk(e))})}function _V(t,e){return e.reduce((n,i,a)=>{const r=t[a]?.done??!1;return n+(i.done&&!r?1:0)},0)}function SV(t,e){const n=t.subtasks.map(a=>({text:a.text,description:a.description??"",report:a.report??""})),i=e.subtasks.map(a=>({text:a.text,description:a.description??"",report:a.report??""}));return t.title!==e.title||t.body!==e.body||JSON.stringify(n)!==JSON.stringify(i)}let DV=0;const Zu=new Map,Od=new Map,ze=WU((t,e)=>({isOpen:!1,loading:!1,dirHandle:null,projectName:null,tasksDirHandle:null,boardTitle:"Project Kanban",columns:[],taskContents:new Map,searchMatches:new Map,viewMode:localStorage.getItem("kandown:view")||"board",density:localStorage.getItem("kandown:density")||"comfortable",filters:{search:"",priority:null,tag:null,assignee:null,ownerType:null},commandOpen:!1,drawerTaskId:null,drawerData:null,currentPage:"board",config:La,recentProjects:[],toasts:[],drawerBaseVersion:null,conflictState:null,showConflictModal:!1,openFolder:async()=>{const n=await dV();if(!n)return;const{projectHandle:i,kandownHandle:a}=n,r=await uf(a),o=i.name;t({dirHandle:a,tasksDirHandle:r,projectName:o}),window.history.pushState({},"",`?p=${encodeURIComponent(o)}`);const u=fr()?S0():null;await D0({id:i.name,name:i.name,handle:i,lastOpened:Date.now(),...u?{kandownDir:u}:{}}),await e().loadConfig(),await e().reloadBoard();const c=await wk();t({recentProjects:c}),e().setupWatcher()},openRecentProject:async n=>{if(!await kA(n.handle,!0)){e().toast("Permission denied","error");return}const a=await yA(n.handle),r=await uf(a),o=n.handle.name;t({dirHandle:a,tasksDirHandle:r,projectName:o}),window.history.pushState({},"",`?p=${encodeURIComponent(o)}`),await D0({...n,lastOpened:Date.now()}),await e().loadConfig(),await e().reloadBoard(),e().setupWatcher()},openServerProject:async()=>{t({loading:!0});try{const n=S0();if(!n)throw new Error("No server root");const i=n.split("/").filter(Boolean).pop()??"Project",a=await FR();yl(a);const r=await BR(),o=await Promise.all(r.map(async p=>{const{frontmatter:m,body:v}=await oV(p),y={...m,id:m.id||p,status:m.status||"Backlog"},{subtasks:_,bodyWithoutSubtasks:w}=bs(v);return{id:p,frontmatter:y,body:w,subtasks:_}}));DA(o);const u=o.map(p=>({frontmatter:p.frontmatter,body:Fd(p.body,p.subtasks)})),c=vA(u,a.board.columns),d=c.reduce((p,m)=>p+m.tasks.length,0),h=new Map;if(d<=10)for(const p of o)h.set(p.frontmatter.id,{frontmatter:p.frontmatter,subtasks:p.subtasks,body:p.body});t({loading:!1,isOpen:!0,config:a,columns:c,boardTitle:"Project Kanban",projectName:i,taskContents:h,searchMatches:new Map}),window.history.pushState({},"",`?p=${encodeURIComponent(i)}`)}catch{t({loading:!1,isOpen:!1}),e().toast("Impossible de charger le projet. Relancez `kandown`.","error")}},tryAutoOpenServerProject:async()=>{if(!fr())return;const n=S0();if(!n)return;const i=await wk(),a=i.find(d=>d.kandownDir===n);if(!a){await e().openServerProject();return}if(!await kA(a.handle,!0)){await e().openServerProject();return}const o=await yA(a.handle),u=await uf(o),c=a.handle.name;t({dirHandle:o,tasksDirHandle:u,projectName:c,recentProjects:i}),window.history.pushState({},"",`?p=${encodeURIComponent(c)}`),await D0({...a,lastOpened:Date.now()}),await e().loadConfig(),await e().reloadBoard(),e().setupWatcher()},loadConfig:async()=>{const{dirHandle:n}=e();if(n)try{const i=await fV(n);i?(t({config:i}),yl(i)):(t({config:La}),yl(La))}catch{t({config:La}),yl(La)}},updateConfig:async n=>{const{dirHandle:i,config:a}=e();if(!i)return;const r=n(a);t({config:r}),yl(r);try{await hV(i,r)}catch(o){e().toast("Failed to save config: "+o.message,"error")}},reloadBoard:async()=>{const{tasksDirHandle:n,config:i}=e();if(n)try{const a=await xV(n);DA(a);const r=a.map(d=>({frontmatter:d.frontmatter,body:Fd(d.body,d.subtasks)})),o=vA(r,i.board.columns);t({boardTitle:"Project Kanban",columns:o});const u=o.reduce((d,h)=>d+h.tasks.length,0),c=new Map;if(u<=10)for(const d of a)c.set(d.frontmatter.id,{frontmatter:d.frontmatter,subtasks:d.subtasks,body:d.body});t({taskContents:c,searchMatches:new Map})}catch(a){e().toast("Failed to load board: "+a.message,"error")}},moveTask:async(n,i,a,r)=>{const{columns:o,tasksDirHandle:u,config:c}=e();if(!u)return;const d=o.find(w=>w.name===i),h=o.find(w=>w.name===a);if(!d||!h)return;const p=d.tasks.findIndex(w=>w.id===n);if(p===-1)return;const m=o.map(w=>({...w,tasks:[...w.tasks]})),v=m.find(w=>w.name===i),y=m.find(w=>w.name===a),[_]=v.tasks.splice(p,1);/done|termin|closed|complet/i.test(a)?_.checked=!0:_.checked=!1,r!==void 0?y.tasks.splice(r,0,_):y.tasks.push(_),t({columns:m});try{const w=i===a?m.filter(b=>b.name===a):m.filter(b=>b.name===i||b.name===a);await SA(u,w,c.board.columns)}catch(w){e().toast("Failed to save: "+w.message,"error"),t({columns:o})}},reorderInColumn:async(n,i,a)=>{const{columns:r,tasksDirHandle:o,config:u}=e();if(!o)return;const c=r.map(p=>({...p,tasks:[...p.tasks]})),d=c.find(p=>p.name===n);if(!d)return;const[h]=d.tasks.splice(i,1);d.tasks.splice(a,0,h),t({columns:c});try{await SA(o,[d],u.board.columns)}catch(p){e().toast("Failed to save: "+p.message,"error"),t({columns:r})}},addColumn:async n=>{const i=n.trim();if(!i)return;const{config:a}=e();a.board.columns.some(r=>r.toLowerCase()===i.toLowerCase())||(await e().updateConfig(r=>({...r,board:{...r.board,columns:[...r.board.columns,i]}})),await e().reloadBoard())},renameColumn:async(n,i)=>{const a=i.trim(),{columns:r,tasksDirHandle:o,config:u}=e();if(!o||!a||a.toLowerCase()===n.toLowerCase())return;if(r.some(h=>h.name.toLowerCase()===a.toLowerCase())){e().toast("Column already exists","error");return}const c=r,d=r.map(h=>h.name===n?{...h,name:a}:h);t({columns:d});try{const h=c.find(p=>p.name===n);h&&await Promise.all(h.tasks.map(async(p,m)=>{const{frontmatter:v,body:y}=await ks(o,p.id);await Hu(o,p.id,{...v,id:p.id,status:a,order:m},y)})),await e().updateConfig(p=>{const m={...p.board.columnColors??{}},v=m[n.toLowerCase()];v&&(m[a.toLowerCase()]=v,delete m[n.toLowerCase()]);const y=p.board.columns.some(_=>_.toLowerCase()===n.toLowerCase())?p.board.columns:[...p.board.columns,n];return{...p,board:{...p.board,columns:y.map(_=>_.toLowerCase()===n.toLowerCase()?a:_),columnColors:m}}}),await e().reloadBoard()}catch(h){e().toast("Failed to rename column: "+h.message,"error"),t({columns:c})}},deleteColumn:async n=>{const{columns:i,tasksDirHandle:a}=e();if(!a)return;const r=i.find(u=>u.name===n);if(!r)return;const o=i;t({columns:i.filter(u=>u.name!==n)});try{await Promise.all(r.tasks.map(u=>bA(a,u.id))),await e().updateConfig(u=>{const c={...u.board.columnColors??{}};return delete c[n.toLowerCase()],{...u,board:{...u.board,columns:u.board.columns.filter(d=>d.toLowerCase()!==n.toLowerCase()),columnColors:c}}}),await e().reloadBoard(),e().toast("Column deleted")}catch(u){e().toast("Failed to delete column: "+u.message,"error"),t({columns:o})}},createTask:async n=>{const{columns:i,tasksDirHandle:a,config:r,taskContents:o}=e();if(!a||!i.length)return null;const u=n||r.board.columns[0]||i[0].name,c=wV(i),d=i.find(m=>m.name===u)?.tasks.length??0,h={id:c,title:"",checked:!1,tags:[],assignee:null,priority:r.fields.priority?r.board.defaultPriority:null,ownerType:r.fields.ownerType?r.board.defaultOwnerType:"",progress:null},p=i.map(m=>m.name===u?{...m,tasks:[...m.tasks,h]}:m);t({columns:p});try{const m={id:c,title:"",status:u,order:d,priority:r.fields.priority?r.board.defaultPriority:"",tags:[],assignee:"",created:new Date().toISOString().slice(0,10),ownerType:r.fields.ownerType?r.board.defaultOwnerType:"",tools:""},v="";await Hu(a,c,m,v);const y=new Map(o);return y.set(c,{frontmatter:m,subtasks:[],body:v}),t({taskContents:y}),e().toast(`Created ${c.replace(/^t/,"")}`),await e().openDrawer(c),c}catch(m){return e().toast("Failed to create: "+m.message,"error"),t({columns:i}),null}},deleteTask:async n=>{const{columns:i,tasksDirHandle:a,taskContents:r}=e();if(!a)return;const o=i.map(d=>({...d,tasks:d.tasks.filter(h=>h.id!==n)}));t({columns:o});const u=new Map(r);u.delete(n);const c=new Map(e().searchMatches);c.delete(n),t({taskContents:u,searchMatches:c});try{await bA(a,n),e().toast("Deleted")}catch(d){e().toast("Failed to delete: "+d.message,"error"),t({columns:i})}},openDrawer:async n=>{const{tasksDirHandle:i}=e();if(i)try{const{frontmatter:a,body:r}=await ks(i,n),{subtasks:o,bodyWithoutSubtasks:u}=bs(r),c={frontmatter:a,subtasks:o,body:u,savedAt:Date.now()};t({drawerTaskId:n,drawerData:{frontmatter:a,subtasks:o,body:u},drawerBaseVersion:c,conflictState:null,showConflictModal:!1})}catch(a){e().toast("Failed to open: "+a.message,"error")}},closeDrawer:()=>t({drawerTaskId:null,drawerData:null,drawerBaseVersion:null,conflictState:null,showConflictModal:!1}),updateDrawerData:n=>{const{drawerData:i}=e();i&&t({drawerData:n(i)})},saveDrawer:async()=>{const{drawerTaskId:n,drawerData:i,tasksDirHandle:a,taskContents:r}=e();if(!n||!i||!a)return;const o=Fd(i.body,i.subtasks),u={...i.frontmatter,id:n};try{await Hu(a,n,u,o),e().toast("Saved"),t({drawerTaskId:null,drawerData:null});const c=new Map(r);c.set(n,{frontmatter:u,subtasks:i.subtasks,body:i.body}),t({taskContents:c}),await e().reloadBoard()}catch(c){e().toast("Failed to save: "+c.message,"error")}},saveDrawerMetadata:async()=>{const{drawerTaskId:n,drawerData:i,tasksDirHandle:a,taskContents:r}=e();if(!n||!i||!a)return;const o=Fd(i.body,i.subtasks),u={...i.frontmatter,id:n};try{await Hu(a,n,u,o);const c=new Map(r);c.set(n,{frontmatter:u,subtasks:i.subtasks,body:i.body}),t({taskContents:c}),await e().reloadBoard()}catch(c){e().toast("Failed to save: "+c.message,"error")}},setViewMode:n=>{localStorage.setItem("kandown:view",n),t({viewMode:n})},setDensity:n=>{localStorage.setItem("kandown:density",n),t({density:n})},setFilter:(n,i)=>{if(t(a=>({filters:{...a.filters,[n]:i}})),n==="search"){const{columns:a,tasksDirHandle:r,taskContents:o}=e(),u=i,c=a.flatMap(d=>d.tasks.map(h=>h.id));if(r){const d=c.filter(h=>!o.has(h));d.length>0?e().loadTaskContents(d).then(()=>{e().computeSearchMatches(u)}):e().computeSearchMatches(u)}}},clearFilters:()=>t({filters:{search:"",priority:null,tag:null,assignee:null,ownerType:null},searchMatches:new Map}),setCommandOpen:n=>t({commandOpen:n}),setCurrentPage:n=>t({currentPage:n}),loadTaskContents:async n=>{const{tasksDirHandle:i}=e();if(!i)return;const a=new Map(e().taskContents);await Promise.all(n.map(async r=>{if(!a.has(r))try{const{frontmatter:o,body:u}=await ks(i,r),{subtasks:c,bodyWithoutSubtasks:d}=bs(u);a.set(r,{frontmatter:o,subtasks:c,body:d})}catch{}})),t({taskContents:a})},computeSearchMatches:n=>{if(!n.trim()){t({searchMatches:new Map});return}const{taskContents:i}=e(),a=new Map,r=n.toLowerCase();for(const[o,u]of i){const c=RR(u,r);c.length>0&&a.set(o,c)}t({searchMatches:a})},toast:(n,i="success")=>{const a=++DV;t(r=>({toasts:[...r.toasts,{id:a,message:n,type:i}]})),setTimeout(()=>{t(r=>({toasts:r.toasts.filter(o=>o.id!==a)}))},2500)},dismissToast:n=>t(i=>({toasts:i.toasts.filter(a=>a.id!==n)})),resolveConflict:async n=>{const{conflictState:i,drawerData:a,tasksDirHandle:r,drawerTaskId:o,drawerBaseVersion:u}=e();if(!(!i||!r||!o))if(n==="reload"){const{frontmatter:c,body:d}=await ks(r,o),{subtasks:h,bodyWithoutSubtasks:p}=bs(d);t({drawerData:{frontmatter:c,subtasks:h,body:p},drawerBaseVersion:{frontmatter:c,subtasks:h,body:p,savedAt:Date.now()},conflictState:null,showConflictModal:!1}),e().toast("Reloaded from disk")}else if(n==="overwrite"){if(a&&o&&u){const c=Fd(a.body,a.subtasks),d={...a.frontmatter,id:o};await Hu(r,o,d,c),t({drawerBaseVersion:{...a,savedAt:Date.now()},conflictState:null,showConflictModal:!1}),e().toast("Overwritten remote changes")}}else t({conflictState:null,showConflictModal:!1})},setupWatcher:()=>{const{dirHandle:n,tasksDirHandle:i}=e();if(!n||!i)return;$u.stop(),Od.forEach(o=>clearTimeout(o)),Od.clear(),$u.start(n,i);const a=(o,u)=>{const c=Od.get(o);c&&clearTimeout(c);const d=Math.max(2e3,e().config.notifications.editDebounceMs),h=setTimeout(()=>{Od.delete(o);const p=e().config;p.notifications.taskEdits&&C0({title:"Task edited",body:`${u} changed on disk.`,config:p})},d);Od.set(o,h)},r=async o=>{const{tasksDirHandle:u,config:c}=e();if(!u)return;const{frontmatter:d,body:h}=await ks(u,o),{subtasks:p,bodyWithoutSubtasks:m}=bs(h),v={id:o,frontmatter:{...d,id:d.id||o,status:d.status||"Backlog"},body:m,subtasks:p},y=xk(v),_=Zu.get(o);if(!_){Zu.set(o,y);return}c.notifications.statusChanges&&_.status!==y.status&&C0({title:"Task status changed",body:`${y.title}: ${_.status} → ${y.status}`,config:c});const w=_V(_.subtasks,y.subtasks);c.notifications.subtaskCompletions&&w>0&&C0({title:"Subtask completed",body:w===1?`${y.title}: 1 subtask completed.`:`${y.title}: ${w} subtasks completed.`,config:c}),SV(_,y)&&a(o,y.title),Zu.set(o,y)};$u.on("configChanged",()=>{e().loadConfig(),e().toast("Settings updated externally","info")}),$u.on("taskChanged",async o=>{const{drawerTaskId:u,drawerBaseVersion:c,tasksDirHandle:d}=e();if(await r(o),u===o&&c&&d){const{frontmatter:h,body:p}=await ks(d,o),{subtasks:m,bodyWithoutSubtasks:v}=bs(p),y=c,_=JSON.stringify(y.frontmatter)!==JSON.stringify(h),w=y.body!==v,b=JSON.stringify(y.subtasks)!==JSON.stringify(m);if(!_&&!w&&!b)return;let C="none";_&&(w||b)?C="full":_?C="metadata-only":(w||b)&&(C="body-only"),t({conflictState:{taskId:o,type:C,local:y,remote:{frontmatter:h,body:v,subtasks:m}},showConflictModal:C==="full"})}else e().reloadBoard()}),$u.on("newTaskDetected",async o=>{const{tasksDirHandle:u}=e();if(u){const{frontmatter:c,body:d}=await ks(u,o),{subtasks:h,bodyWithoutSubtasks:p}=bs(d);Zu.set(o,xk({id:o,frontmatter:{...c,id:c.id||o,status:c.status||"Backlog"},body:p,subtasks:h}))}e().reloadBoard()})}}));wk().then(t=>{ze.setState({recentProjects:t})});yl(La);window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>{yl(ze.getState().config)});function CV(t){const e=i7(t,{stiffness:180,damping:22,mass:.8});return H.useEffect(()=>{e.set(t)},[t,e]),oR(e,i=>Math.round(i).toString())}const _k="0.3.4",AV=({className:t})=>x.jsxs("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 150 150",className:t,fill:"currentColor",children:[x.jsx("path",{d:"m57.6 64.6 0.1-0.1v-31.3c-0.1-3.5-2.7-5.6-5.7-5.6h-16.3v59.6c0.2-1.3 0.9-2.6 1.7-3.2l20.2-19.4z"}),x.jsx("path",{d:"m87.6 43.8c-3.4 0.1-7.1 1.3-9.9 3.7l-38.5 38.7c-2.1 2.1-3.4 4.8-3.5 7.5v26.7l77.5-76.4v-0.2h-25.6z"}),x.jsx("path",{d:"m108.1 96.4-6 5-22.4-20.8-14.6 14.2 21.1 21.4-5 4.1c-0.6 0.5-0.3 0.9 0.2 0.9h27.6c0.4 0 0.7-0.4 0.7-0.6v-24.8c0-0.7-1.1-0.3-1.6 0.3v0.3z"})]});function EV(){const{t}=di(),e=ze(O=>O.dirHandle);ze(O=>O.projectName);const n=ze(O=>O.columns),i=ze(O=>O.openFolder),a=ze(O=>O.reloadBoard),r=ze(O=>O.createTask),o=ze(O=>O.setCommandOpen),u=ze(O=>O.viewMode),c=ze(O=>O.setViewMode),d=ze(O=>O.density),h=ze(O=>O.setDensity),p=ze(O=>O.setCurrentPage),m=ze(O=>O.recentProjects),v=ze(O=>O.openRecentProject),y=ze(O=>O.filters),_=ze(O=>O.setFilter),w=ze(O=>O.clearFilters),b=ze(O=>O.config.fields),[C,D]=H.useState(!1),A=H.useRef(null),M=H.useRef(null),T=n.reduce((O,$)=>O+$.tasks.length,0),F=CV(T),R=[];b.priority&&y.priority&&R.push({type:"priority",label:y.priority,value:y.priority}),b.tags&&y.tag&&R.push({type:"tag",label:"#"+y.tag,value:y.tag}),b.assignee&&y.assignee&&R.push({type:"assignee",label:"@"+y.assignee,value:y.assignee});const P=[{label:t("filterBar.ownerAll"),value:""},{label:t("filterBar.ownerHuman"),value:"human"},{label:t("filterBar.ownerAI"),value:"ai"}],E=R.length>0||y.search||b.ownerType&&y.ownerType;return H.useEffect(()=>{if(!C)return;const O=$=>{A.current&&!A.current.contains($.target)&&D(!1)};return document.addEventListener("mousedown",O),()=>document.removeEventListener("mousedown",O)},[C]),x.jsxs("header",{className:"flex items-center justify-between px-5 h-14 border-b border-border bg-bg relative z-10",children:[x.jsxs("div",{className:"flex items-center gap-3 min-w-0",children:[x.jsx("div",{className:"flex items-center gap-2 flex-shrink-0",children:x.jsxs("button",{onClick:()=>window.history.pushState({},"",window.location.pathname),className:"flex items-center gap-2 cursor-pointer",children:[x.jsx(AV,{className:"w-[36px] h-[36px] dark:text-white text-black"}),x.jsx("span",{className:"text-[15px] font-semibold tracking-tight",children:t("app.name")}),x.jsxs("span",{className:"text-[11px] font-mono font-bold text-red-500",children:["v",_k]})]})}),e&&x.jsxs(x.Fragment,{children:[x.jsx("div",{className:"w-px h-[18px] bg-border flex-shrink-0"}),x.jsxs("div",{className:"flex items-center gap-2 px-2.5 h-8 bg-bg-2 border border-border rounded-[8px] min-w-[180px] max-w-[260px] focus-within:border-border-focus focus-within:bg-bg-3 transition-colors",children:[x.jsx(oi.Search,{size:14,className:"text-fg-muted flex-shrink-0"}),x.jsx("input",{ref:M,type:"text",placeholder:t("filterBar.searchPlaceholder"),value:y.search,onChange:O=>_("search",O.target.value),className:"bg-transparent border-none outline-none text-fg text-[13px] w-full placeholder:text-fg-muted"}),y.search&&x.jsx("button",{onClick:()=>_("search",""),className:"text-fg-muted hover:text-fg flex-shrink-0",children:x.jsx(oi.X,{size:14})})]}),x.jsxs("div",{className:"flex items-center gap-1.5 flex-wrap overflow-hidden",children:[x.jsx(ba,{children:R.map(O=>x.jsxs(Xt.button,{initial:{opacity:0,scale:.9},animate:{opacity:1,scale:1},exit:{opacity:0,scale:.9},transition:{duration:.15},onClick:()=>_(O.type,null),className:"inline-flex items-center gap-1 h-6 px-2 text-[12px] text-fg bg-bg-3 border border-border-strong rounded-[4px] hover:bg-bg-hover transition-colors",children:[O.label,x.jsx(oi.X,{size:10,className:"text-fg-muted"})]},O.type+O.value))}),b.ownerType&&x.jsx("div",{className:"flex items-center h-6 border border-border-strong rounded-[4px] overflow-hidden",children:P.map(O=>x.jsx("button",{onClick:()=>_("ownerType",O.value),className:`h-full px-2 text-[12px] transition-colors ${y.ownerType===O.value?"bg-bg-hover text-fg":"text-fg-muted hover:text-fg"}`,children:O.label},O.value))}),E&&x.jsx("button",{onClick:w,className:"text-[12px] text-fg-muted hover:text-fg transition-colors",children:t("filterBar.clearAll")})]})]}),e&&x.jsxs(x.Fragment,{children:[x.jsx("div",{className:"w-px h-[18px] bg-border flex-shrink-0"}),x.jsxs("div",{className:"relative flex-shrink-0",ref:A,children:[x.jsxs("button",{onClick:()=>D(O=>!O),className:"flex items-center gap-1.5 px-2 py-1 text-[14px] text-fg-dim hover:text-fg hover:bg-bg-2 rounded-[6px] transition-colors",children:[x.jsx(oi.Folder,{size:12,className:"text-fg-muted"}),x.jsx("span",{children:e.name}),x.jsx(oi.ChevronDown,{size:10,className:"opacity-60"})]}),x.jsx(ba,{children:C&&x.jsx(Xt.div,{initial:{opacity:0,y:-4,scale:.98},animate:{opacity:1,y:0,scale:1},exit:{opacity:0,y:-4,scale:.98},transition:{duration:.12},className:"absolute top-full left-0 mt-1.5 min-w-[240px] glass rounded-[8px] shadow-[0_16px_48px_rgba(0,0,0,0.5)] overflow-hidden z-50",children:x.jsxs("div",{className:"py-1.5",children:[m.length>0&&x.jsxs(x.Fragment,{children:[x.jsx("div",{className:"px-3 py-1 text-[11px] font-semibold uppercase tracking-wider text-fg-faint",children:t("header.recentProjects")}),m.map(O=>x.jsxs("button",{onClick:()=>{D(!1),v(O)},className:"w-full flex items-center gap-2 px-3 py-1.5 text-[13.5px] text-left hover:bg-bg-3 transition-colors",children:[x.jsx(oi.Folder,{size:12,className:"text-fg-muted"}),x.jsx("span",{className:"truncate",children:O.name}),O.id===e.name&&x.jsx(oi.Check,{size:12,className:"ml-auto text-success"})]},O.id)),x.jsx("div",{className:"h-px bg-border my-1.5 mx-2"})]}),x.jsxs("button",{onClick:()=>{D(!1),i()},className:"w-full flex items-center gap-2 px-3 py-1.5 text-[13.5px] text-left hover:bg-bg-3 transition-colors",children:[x.jsx(oi.Plus,{size:12,className:"text-fg-muted"}),x.jsx("span",{children:t("header.openFolder...")})]})]})})})]})]})]}),x.jsx("div",{className:"flex items-center gap-1.5 flex-shrink-0",children:e?x.jsxs(x.Fragment,{children:[x.jsxs("div",{className:"flex items-center gap-2 mr-2 text-[13px] text-fg-muted",children:[x.jsx("span",{className:"inline-block w-1.5 h-1.5 rounded-full bg-success"}),x.jsx(Xt.span,{className:"tabular-nums",children:F}),x.jsx("span",{children:t("header.tasks")})]}),x.jsxs("div",{className:"flex items-center bg-bg-2 border border-border rounded-[10px] p-0.5 h-11",children:[x.jsx("button",{onClick:()=>c("board"),className:`w-10 h-10 inline-flex items-center justify-center rounded-[8px] transition-all ${u==="board"?"bg-bg-3 text-fg":"text-fg-muted hover:text-fg"}`,title:t("common.board"),children:x.jsx(oi.LayoutBoard,{size:20})}),x.jsx("button",{onClick:()=>c("list"),className:`w-10 h-10 inline-flex items-center justify-center rounded-[8px] transition-all ${u==="list"?"bg-bg-3 text-fg":"text-fg-muted hover:text-fg"}`,title:t("common.list"),children:x.jsx(oi.LayoutList,{size:20})})]}),x.jsx(Qi,{variant:"icon",icon:"Density",onClick:()=>h(d==="compact"?"comfortable":"compact"),title:`Density: ${d}`}),x.jsx(Qi,{variant:"icon",icon:"Settings",onClick:()=>p("settings"),title:t("common.settings")}),x.jsx(Qi,{variant:"secondary",icon:"Search",label:t("common.search"),shortcut:"⌘K",onClick:()=>o(!0),title:"Command palette (⌘K)"}),x.jsx(Qi,{variant:"icon",icon:"Refresh",onClick:a,title:`${t("common.reload")} (R)`}),x.jsx(Qi,{variant:"primary",icon:"Plus",label:t("common.newTask"),shortcut:"N",onClick:()=>r()})]}):x.jsx(Qi,{variant:"primary",label:t("common.openFolder"),onClick:i})})]})}/**
116
116
  * @license @tabler/icons-react v3.41.1 - MIT
117
117
  *
118
118
  * This source code is licensed under the MIT license.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kandown",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "File-based Kanban engine backed by Markdown. Zero backend, AI-agent friendly.",
5
5
  "type": "module",
6
6
  "bin": {