kandown 0.3.3 → 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 +318 -427
- package/bin/kandown.js +18 -6
- package/dist/index.html +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,226 +1,251 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="logo.svg" width="
|
|
2
|
+
<img src="logo.svg" width="140" height="140" alt="Kandown logo">
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
<h1 align="center">Kandown</h1>
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
+
---
|
|
16
31
|
|
|
17
|
-
|
|
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
|
-
|
|
34
|
+
### 1. Install globally
|
|
21
35
|
|
|
22
|
-
|
|
36
|
+
```bash
|
|
37
|
+
npm i -g kandown
|
|
38
|
+
```
|
|
23
39
|
|
|
24
|
-
|
|
40
|
+
### 2. Initialize in any project
|
|
25
41
|
|
|
26
42
|
```bash
|
|
27
43
|
cd my-project
|
|
28
|
-
|
|
44
|
+
kandown init
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### 3. Launch your board
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
kandown
|
|
29
51
|
```
|
|
30
52
|
|
|
31
|
-
|
|
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
|
-
|
|
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
|
|
36
|
-
├── kandown.json
|
|
37
|
-
├── tasks/
|
|
38
|
-
├── AGENT.md
|
|
39
|
-
└── README.md
|
|
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
|
|
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
|
-
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## ✨ Features
|
|
92
|
+
|
|
93
|
+
### Board & Views
|
|
94
|
+
|
|
95
|
+
| Feature | Description |
|
|
78
96
|
|---|---|
|
|
79
|
-
|
|
|
80
|
-
|
|
|
81
|
-
|
|
|
82
|
-
| `
|
|
83
|
-
|
|
|
84
|
-
|
|
|
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
|
-
|
|
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
|
-
#
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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 |
|
|
142
|
+
| Command | Description |
|
|
113
143
|
|---|---|
|
|
114
|
-
|
|
|
115
|
-
| `
|
|
116
|
-
| `
|
|
117
|
-
| `
|
|
118
|
-
| `
|
|
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
|
-
|
|
151
|
+
### Init Options
|
|
122
152
|
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
143
|
-
| `Enter` | Open task detail
|
|
144
|
-
| `a` |
|
|
145
|
-
| `r` | Reload
|
|
146
|
-
| `q` / `Esc` | Quit
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
209
|
+
### How it works
|
|
167
210
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
217
|
+
> **Tip:** AI agents can also edit task files directly — Kandown detects external changes and syncs automatically.
|
|
204
218
|
|
|
205
|
-
|
|
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
|
-
##
|
|
221
|
+
## ⌨️ Keyboard Shortcuts
|
|
215
222
|
|
|
216
|
-
###
|
|
223
|
+
### Web UI
|
|
217
224
|
|
|
218
|
-
|
|
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:
|
|
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
|
-
#
|
|
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
|
-
- [
|
|
242
|
-
- [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
|
316
|
+
| Section | Key options |
|
|
317
317
|
|---|---|
|
|
318
|
-
| `
|
|
319
|
-
| `
|
|
320
|
-
| `
|
|
321
|
-
| `
|
|
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
|
-
|
|
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
|
-
|
|
325
|
+
---
|
|
335
326
|
|
|
336
|
-
|
|
327
|
+
## 🌐 Browser Support
|
|
337
328
|
|
|
338
|
-
|
|
|
329
|
+
| Browser | Supported |
|
|
339
330
|
|---|---|
|
|
340
|
-
|
|
|
341
|
-
|
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
342
|
+
## 🎨 Appearance
|
|
379
343
|
|
|
380
|
-
###
|
|
344
|
+
### Skins
|
|
381
345
|
|
|
382
|
-
|
|
|
346
|
+
| Skin | Description |
|
|
383
347
|
|---|---|
|
|
384
|
-
| `
|
|
385
|
-
| `
|
|
386
|
-
| `
|
|
387
|
-
| `
|
|
388
|
-
| `
|
|
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
|
-
###
|
|
354
|
+
### Theme Modes
|
|
412
355
|
|
|
413
|
-
|
|
|
356
|
+
| Mode | Behavior |
|
|
414
357
|
|---|---|
|
|
415
|
-
| `
|
|
416
|
-
| `
|
|
417
|
-
| `
|
|
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
|
-
###
|
|
362
|
+
### Fonts
|
|
424
363
|
|
|
425
|
-
|
|
|
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
|
-
| `
|
|
442
|
-
| `
|
|
443
|
-
| `
|
|
444
|
-
| `
|
|
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
|
-
|
|
372
|
+
---
|
|
447
373
|
|
|
448
|
-
|
|
374
|
+
## 🛠️ Development
|
|
449
375
|
|
|
450
|
-
|
|
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
|
|
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` |
|
|
493
|
-
| `pnpm dev:cli` | Watch-mode build for the CLI TUI
|
|
494
|
-
| `pnpm build` | Full build: typecheck →
|
|
495
|
-
| `pnpm build:cli` |
|
|
496
|
-
| `pnpm preview` | Preview the Vite production build
|
|
497
|
-
| `pnpm typecheck` |
|
|
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
|
-
|
|
396
|
+
### Dev Workflow (CLI / TUI)
|
|
514
397
|
|
|
515
398
|
```bash
|
|
516
|
-
# Terminal 1
|
|
399
|
+
# Terminal 1 — watch and rebuild on save
|
|
517
400
|
pnpm dev:cli
|
|
518
401
|
|
|
519
|
-
# Terminal 2
|
|
402
|
+
# Terminal 2 — test the board TUI
|
|
520
403
|
node bin/kandown.js board
|
|
521
404
|
|
|
522
|
-
# Or
|
|
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
|
-
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
-
|
|
416
|
+
---
|
|
547
417
|
|
|
548
|
-
|
|
418
|
+
## 🏗️ Architecture
|
|
549
419
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
tmux new-session -s dev
|
|
420
|
+
<details>
|
|
421
|
+
<summary>Project structure</summary>
|
|
553
422
|
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
|
|
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
|
-
|
|
449
|
+
<details>
|
|
450
|
+
<summary>Runtime flow</summary>
|
|
566
451
|
|
|
567
|
-
|
|
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
|
-
|
|
460
|
+
</details>
|
|
570
461
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
- Brave
|
|
574
|
-
- Opera
|
|
462
|
+
<details>
|
|
463
|
+
<summary>Key modules</summary>
|
|
575
464
|
|
|
576
|
-
|
|
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
|
-
|
|
579
|
-
- Safari
|
|
475
|
+
</details>
|
|
580
476
|
|
|
581
|
-
|
|
477
|
+
---
|
|
582
478
|
|
|
583
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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() {
|
|
@@ -626,8 +628,8 @@ function listen(server, port) {
|
|
|
626
628
|
}
|
|
627
629
|
|
|
628
630
|
async function listenOnAvailablePort(kandownDir, preferredPort) {
|
|
629
|
-
const startPort = preferredPort ??
|
|
630
|
-
const endPort = preferredPort ??
|
|
631
|
+
const startPort = preferredPort ?? START_PORT_RANGE;
|
|
632
|
+
const endPort = preferredPort ?? END_PORT_RANGE;
|
|
631
633
|
|
|
632
634
|
for (let port = startPort; port <= endPort; port++) {
|
|
633
635
|
const server = createServeServer(kandownDir);
|
|
@@ -640,7 +642,7 @@ async function listenOnAvailablePort(kandownDir, preferredPort) {
|
|
|
640
642
|
}
|
|
641
643
|
|
|
642
644
|
const range = preferredPort === null
|
|
643
|
-
? `${
|
|
645
|
+
? `${START_PORT_RANGE}-${END_PORT_RANGE}`
|
|
644
646
|
: String(preferredPort);
|
|
645
647
|
err(`No free port available in ${c.bold}${range}${c.reset}.`);
|
|
646
648
|
process.exit(1);
|
|
@@ -690,17 +692,27 @@ async function cmdServe(rawArgs) {
|
|
|
690
692
|
}
|
|
691
693
|
|
|
692
694
|
/**
|
|
693
|
-
* 📖 Opens a
|
|
695
|
+
* 📖 Opens a URL in the system default browser after confirming the server is ready.
|
|
694
696
|
* Non-blocking — spawns the opener and returns immediately.
|
|
695
697
|
* macOS: open, Linux: xdg-open, Windows: start (via cmd.exe).
|
|
696
698
|
*/
|
|
697
|
-
function openInBrowser(
|
|
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
|
+
|
|
698
710
|
const opener = process.platform === 'darwin'
|
|
699
711
|
? 'open'
|
|
700
712
|
: process.platform === 'win32'
|
|
701
713
|
? 'cmd'
|
|
702
714
|
: 'xdg-open';
|
|
703
|
-
const args = process.platform === 'win32' ? ['/c', 'start', '',
|
|
715
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
704
716
|
const child = spawn(opener, args, { detached: true, stdio: 'ignore' });
|
|
705
717
|
child.on('error', (e) => warn(`Could not open browser automatically: ${e.message}`));
|
|
706
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.3",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.
|