devglide 0.1.1

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.
Files changed (252) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +338 -0
  3. package/bin/claude-md-template.js +94 -0
  4. package/bin/devglide.js +387 -0
  5. package/package.json +85 -0
  6. package/pnpm-workspace.yaml +3 -0
  7. package/src/apps/coder/.turbo/turbo-lint.log +5 -0
  8. package/src/apps/coder/package.json +16 -0
  9. package/src/apps/coder/public/favicon.svg +7 -0
  10. package/src/apps/coder/public/page.css +275 -0
  11. package/src/apps/coder/public/page.js +528 -0
  12. package/src/apps/coder/server.js +3 -0
  13. package/src/apps/documentation/public/page.css +597 -0
  14. package/src/apps/documentation/public/page.js +609 -0
  15. package/src/apps/kanban/.turbo/turbo-lint.log +97 -0
  16. package/src/apps/kanban/.turbo/turbo-typecheck.log +5 -0
  17. package/src/apps/kanban/package.json +32 -0
  18. package/src/apps/kanban/public/favicon.svg +7 -0
  19. package/src/apps/kanban/public/page.css +1010 -0
  20. package/src/apps/kanban/public/page.js +1730 -0
  21. package/src/apps/kanban/public/vendor/marked.min.js +6 -0
  22. package/src/apps/kanban/public/vendor/sortable.min.js +2 -0
  23. package/src/apps/kanban/src/db.ts +319 -0
  24. package/src/apps/kanban/src/index.ts +14 -0
  25. package/src/apps/kanban/src/mcp-helpers.test.ts +88 -0
  26. package/src/apps/kanban/src/mcp-helpers.ts +60 -0
  27. package/src/apps/kanban/src/mcp.ts +59 -0
  28. package/src/apps/kanban/src/routes/attachments.ts +161 -0
  29. package/src/apps/kanban/src/routes/features.ts +233 -0
  30. package/src/apps/kanban/src/routes/issues.ts +373 -0
  31. package/src/apps/kanban/src/tools/feature-tools.ts +164 -0
  32. package/src/apps/kanban/src/tools/item-tools.ts +307 -0
  33. package/src/apps/kanban/src/tools/versioned-entry-tools.ts +72 -0
  34. package/src/apps/kanban/tsconfig.check.json +9 -0
  35. package/src/apps/kanban/tsconfig.json +9 -0
  36. package/src/apps/keymap/.turbo/turbo-lint.log +5 -0
  37. package/src/apps/keymap/package.json +16 -0
  38. package/src/apps/keymap/public/page.css +275 -0
  39. package/src/apps/keymap/public/page.js +294 -0
  40. package/src/apps/keymap/server.js +25 -0
  41. package/src/apps/log/.turbo/turbo-build.log +5 -0
  42. package/src/apps/log/.turbo/turbo-lint.log +45 -0
  43. package/src/apps/log/.turbo/turbo-typecheck.log +5 -0
  44. package/src/apps/log/node_modules/.bin/tsc +21 -0
  45. package/src/apps/log/node_modules/.bin/tsserver +21 -0
  46. package/src/apps/log/node_modules/.bin/tsx +21 -0
  47. package/src/apps/log/package.json +36 -0
  48. package/src/apps/log/public/console-sniffer.js +221 -0
  49. package/src/apps/log/public/favicon.svg +7 -0
  50. package/src/apps/log/public/page.css +322 -0
  51. package/src/apps/log/public/page.js +463 -0
  52. package/src/apps/log/src/index.ts +9 -0
  53. package/src/apps/log/src/mcp.ts +122 -0
  54. package/src/apps/log/src/routes/log.ts +333 -0
  55. package/src/apps/log/src/routes/status.ts +25 -0
  56. package/src/apps/log/src/server-sniffer.ts +118 -0
  57. package/src/apps/log/src/services/file-patterns.ts +39 -0
  58. package/src/apps/log/src/services/file-tailer.ts +228 -0
  59. package/src/apps/log/src/services/line-parser.ts +94 -0
  60. package/src/apps/log/src/services/log-writer.ts +39 -0
  61. package/src/apps/log/tsconfig.json +8 -0
  62. package/src/apps/prompts/.turbo/turbo-build.log +5 -0
  63. package/src/apps/prompts/.turbo/turbo-lint.log +24 -0
  64. package/src/apps/prompts/.turbo/turbo-typecheck.log +5 -0
  65. package/src/apps/prompts/mcp.ts +175 -0
  66. package/src/apps/prompts/node_modules/.bin/tsc +21 -0
  67. package/src/apps/prompts/node_modules/.bin/tsserver +21 -0
  68. package/src/apps/prompts/node_modules/.bin/tsx +21 -0
  69. package/src/apps/prompts/package.json +25 -0
  70. package/src/apps/prompts/public/page.css +315 -0
  71. package/src/apps/prompts/public/page.js +541 -0
  72. package/src/apps/prompts/services/prompt-store.ts +212 -0
  73. package/src/apps/prompts/src/index.ts +9 -0
  74. package/src/apps/prompts/tsconfig.json +8 -0
  75. package/src/apps/prompts/types.ts +27 -0
  76. package/src/apps/shell/.turbo/turbo-build.log +5 -0
  77. package/src/apps/shell/.turbo/turbo-lint.log +34 -0
  78. package/src/apps/shell/.turbo/turbo-typecheck.log +5 -0
  79. package/src/apps/shell/package.json +35 -0
  80. package/src/apps/shell/public/favicon.svg +7 -0
  81. package/src/apps/shell/public/page.css +407 -0
  82. package/src/apps/shell/public/page.js +1577 -0
  83. package/src/apps/shell/src/index.ts +150 -0
  84. package/src/apps/shell/src/mcp.ts +398 -0
  85. package/src/apps/shell/src/shell-types.ts +41 -0
  86. package/src/apps/shell/tsconfig.json +8 -0
  87. package/src/apps/test/.turbo/turbo-build.log +5 -0
  88. package/src/apps/test/.turbo/turbo-lint.log +27 -0
  89. package/src/apps/test/.turbo/turbo-typecheck.log +5 -0
  90. package/src/apps/test/node_modules/.bin/tsc +21 -0
  91. package/src/apps/test/node_modules/.bin/tsserver +21 -0
  92. package/src/apps/test/node_modules/.bin/tsx +21 -0
  93. package/src/apps/test/node_modules/.bin/uuid +21 -0
  94. package/src/apps/test/package.json +35 -0
  95. package/src/apps/test/public/favicon.svg +7 -0
  96. package/src/apps/test/public/page.css +499 -0
  97. package/src/apps/test/public/page.js +417 -0
  98. package/src/apps/test/public/scenario-runner.js +450 -0
  99. package/src/apps/test/src/index.ts +9 -0
  100. package/src/apps/test/src/mcp.ts +192 -0
  101. package/src/apps/test/src/routes/trigger.ts +285 -0
  102. package/src/apps/test/src/services/scenario-broadcaster.ts +60 -0
  103. package/src/apps/test/src/services/scenario-manager.ts +361 -0
  104. package/src/apps/test/src/services/scenario-store.ts +145 -0
  105. package/src/apps/test/tsconfig.json +8 -0
  106. package/src/apps/vocabulary/.turbo/turbo-build.log +5 -0
  107. package/src/apps/vocabulary/.turbo/turbo-lint.log +25 -0
  108. package/src/apps/vocabulary/.turbo/turbo-typecheck.log +5 -0
  109. package/src/apps/vocabulary/mcp.ts +173 -0
  110. package/src/apps/vocabulary/node_modules/.bin/tsc +21 -0
  111. package/src/apps/vocabulary/node_modules/.bin/tsserver +21 -0
  112. package/src/apps/vocabulary/node_modules/.bin/tsx +21 -0
  113. package/src/apps/vocabulary/package.json +25 -0
  114. package/src/apps/vocabulary/public/page.css +247 -0
  115. package/src/apps/vocabulary/public/page.js +444 -0
  116. package/src/apps/vocabulary/services/vocabulary-store.ts +179 -0
  117. package/src/apps/vocabulary/src/index.ts +10 -0
  118. package/src/apps/vocabulary/tsconfig.json +8 -0
  119. package/src/apps/vocabulary/types.ts +22 -0
  120. package/src/apps/voice/.turbo/turbo-build.log +5 -0
  121. package/src/apps/voice/.turbo/turbo-lint.log +43 -0
  122. package/src/apps/voice/.turbo/turbo-typecheck.log +5 -0
  123. package/src/apps/voice/node_modules/.bin/openai +21 -0
  124. package/src/apps/voice/node_modules/.bin/tsc +21 -0
  125. package/src/apps/voice/node_modules/.bin/tsserver +21 -0
  126. package/src/apps/voice/node_modules/.bin/tsx +21 -0
  127. package/src/apps/voice/package.json +35 -0
  128. package/src/apps/voice/public/favicon.svg +7 -0
  129. package/src/apps/voice/public/page.css +388 -0
  130. package/src/apps/voice/public/page.js +718 -0
  131. package/src/apps/voice/src/index.ts +10 -0
  132. package/src/apps/voice/src/mcp.ts +70 -0
  133. package/src/apps/voice/src/providers/index.ts +85 -0
  134. package/src/apps/voice/src/providers/openai-compatible.ts +94 -0
  135. package/src/apps/voice/src/providers/types.ts +27 -0
  136. package/src/apps/voice/src/routes/config.ts +118 -0
  137. package/src/apps/voice/src/routes/transcribe.ts +90 -0
  138. package/src/apps/voice/src/services/config-store.ts +129 -0
  139. package/src/apps/voice/src/services/stats.ts +108 -0
  140. package/src/apps/voice/src/transcribe.ts +11 -0
  141. package/src/apps/voice/src/utils/mime.ts +16 -0
  142. package/src/apps/voice/tsconfig.json +8 -0
  143. package/src/apps/workflow/.turbo/turbo-build.log +5 -0
  144. package/src/apps/workflow/.turbo/turbo-lint.log +96 -0
  145. package/src/apps/workflow/.turbo/turbo-typecheck.log +5 -0
  146. package/src/apps/workflow/engine/executors/decision-executor.ts +87 -0
  147. package/src/apps/workflow/engine/executors/file-executor.ts +90 -0
  148. package/src/apps/workflow/engine/executors/git-executor.ts +137 -0
  149. package/src/apps/workflow/engine/executors/http-executor.ts +65 -0
  150. package/src/apps/workflow/engine/executors/index.ts +28 -0
  151. package/src/apps/workflow/engine/executors/kanban-executor.ts +154 -0
  152. package/src/apps/workflow/engine/executors/llm-executor.ts +46 -0
  153. package/src/apps/workflow/engine/executors/log-executor.ts +62 -0
  154. package/src/apps/workflow/engine/executors/loop-executor.ts +14 -0
  155. package/src/apps/workflow/engine/executors/shell-executor.ts +107 -0
  156. package/src/apps/workflow/engine/executors/sub-workflow-executor.ts +61 -0
  157. package/src/apps/workflow/engine/executors/test-executor.ts +73 -0
  158. package/src/apps/workflow/engine/executors/trigger-executor.ts +39 -0
  159. package/src/apps/workflow/engine/expression-evaluator.ts +117 -0
  160. package/src/apps/workflow/engine/graph-runner.ts +438 -0
  161. package/src/apps/workflow/engine/node-executor.ts +104 -0
  162. package/src/apps/workflow/engine/node-registry.ts +15 -0
  163. package/src/apps/workflow/engine/variable-resolver.ts +109 -0
  164. package/src/apps/workflow/mcp.ts +223 -0
  165. package/src/apps/workflow/node_modules/.bin/tsc +21 -0
  166. package/src/apps/workflow/node_modules/.bin/tsserver +21 -0
  167. package/src/apps/workflow/node_modules/.bin/tsx +21 -0
  168. package/src/apps/workflow/package.json +25 -0
  169. package/src/apps/workflow/public/editor/canvas.js +366 -0
  170. package/src/apps/workflow/public/editor/drag-manager.js +326 -0
  171. package/src/apps/workflow/public/editor/edge-renderer.js +235 -0
  172. package/src/apps/workflow/public/editor/history-manager.js +147 -0
  173. package/src/apps/workflow/public/editor/layout-engine.js +159 -0
  174. package/src/apps/workflow/public/editor/node-renderer.js +199 -0
  175. package/src/apps/workflow/public/editor/selection-manager.js +193 -0
  176. package/src/apps/workflow/public/favicon.svg +7 -0
  177. package/src/apps/workflow/public/models/node-types.js +300 -0
  178. package/src/apps/workflow/public/models/workflow-model.js +257 -0
  179. package/src/apps/workflow/public/page.css +406 -0
  180. package/src/apps/workflow/public/page.js +658 -0
  181. package/src/apps/workflow/public/panels/inspector.js +360 -0
  182. package/src/apps/workflow/public/panels/palette.js +106 -0
  183. package/src/apps/workflow/public/panels/run-view.js +275 -0
  184. package/src/apps/workflow/public/panels/toolbar.js +232 -0
  185. package/src/apps/workflow/public/panels/workflow-list.js +237 -0
  186. package/src/apps/workflow/public/state/store.js +47 -0
  187. package/src/apps/workflow/services/custom-node-loader.ts +48 -0
  188. package/src/apps/workflow/services/legacy-converter.ts +72 -0
  189. package/src/apps/workflow/services/run-manager.ts +190 -0
  190. package/src/apps/workflow/services/workflow-store.ts +424 -0
  191. package/src/apps/workflow/services/workflow-validator.test.ts +103 -0
  192. package/src/apps/workflow/services/workflow-validator.ts +98 -0
  193. package/src/apps/workflow/src/index.ts +10 -0
  194. package/src/apps/workflow/templates/ci-pipeline.json +18 -0
  195. package/src/apps/workflow/templates/code-review.json +22 -0
  196. package/src/apps/workflow/templates/kanban-testing.json +24 -0
  197. package/src/apps/workflow/tsconfig.json +8 -0
  198. package/src/apps/workflow/types.ts +268 -0
  199. package/src/packages/auth-middleware.ts +14 -0
  200. package/src/packages/design-tokens/.turbo/turbo-build.log +10 -0
  201. package/src/packages/design-tokens/STYLEGUIDE.md +414 -0
  202. package/src/packages/design-tokens/build.js +413 -0
  203. package/src/packages/design-tokens/demo/index.html +1367 -0
  204. package/src/packages/design-tokens/demo/proposition-a.html +717 -0
  205. package/src/packages/design-tokens/demo/proposition-b.html +1239 -0
  206. package/src/packages/design-tokens/demo/proposition-c.html +1049 -0
  207. package/src/packages/design-tokens/dist/tailwind-preset.js +115 -0
  208. package/src/packages/design-tokens/dist/tokens.css +345 -0
  209. package/src/packages/design-tokens/dist/tokens.d.ts +229 -0
  210. package/src/packages/design-tokens/dist/tokens.js +386 -0
  211. package/src/packages/design-tokens/package.json +25 -0
  212. package/src/packages/design-tokens/tokens.json +228 -0
  213. package/src/packages/devtools-middleware.ts +22 -0
  214. package/src/packages/eslint-config/index.js +63 -0
  215. package/src/packages/eslint-config/node_modules/.bin/eslint +21 -0
  216. package/src/packages/eslint-config/package.json +18 -0
  217. package/src/packages/json-file-store.ts +232 -0
  218. package/src/packages/mcp-utils/.turbo/turbo-build.log +5 -0
  219. package/src/packages/mcp-utils/dist/index.d.ts +33 -0
  220. package/src/packages/mcp-utils/dist/index.d.ts.map +1 -0
  221. package/src/packages/mcp-utils/dist/index.js +126 -0
  222. package/src/packages/mcp-utils/dist/index.js.map +1 -0
  223. package/src/packages/mcp-utils/node_modules/.bin/tsc +21 -0
  224. package/src/packages/mcp-utils/node_modules/.bin/tsserver +21 -0
  225. package/src/packages/mcp-utils/package.json +32 -0
  226. package/src/packages/mcp-utils/src/index.ts +171 -0
  227. package/src/packages/mcp-utils/tsconfig.json +9 -0
  228. package/src/packages/paths.ts +18 -0
  229. package/src/packages/project-context/index.js +55 -0
  230. package/src/packages/project-context/package.json +13 -0
  231. package/src/packages/project-store.ts +127 -0
  232. package/src/packages/server-sniffer.ts +132 -0
  233. package/src/packages/shared-assets/favicon.svg +7 -0
  234. package/src/packages/shared-assets/keymap-registry.js +512 -0
  235. package/src/packages/shared-assets/logo.svg +6 -0
  236. package/src/packages/shared-assets/package.json +11 -0
  237. package/src/packages/shared-assets/ui-utils.js +48 -0
  238. package/src/packages/shared-assets/voice-widget.d.ts +37 -0
  239. package/src/packages/shared-assets/voice-widget.js +695 -0
  240. package/src/packages/shared-types/.turbo/turbo-build.log +5 -0
  241. package/src/packages/shared-types/dist/index.d.ts +39 -0
  242. package/src/packages/shared-types/dist/index.d.ts.map +1 -0
  243. package/src/packages/shared-types/node_modules/.bin/tsc +21 -0
  244. package/src/packages/shared-types/node_modules/.bin/tsserver +21 -0
  245. package/src/packages/shared-types/package.json +25 -0
  246. package/src/packages/shared-types/src/index.ts +41 -0
  247. package/src/packages/shared-types/tsconfig.json +11 -0
  248. package/src/packages/tsconfig/base.json +15 -0
  249. package/src/packages/tsconfig/next.json +14 -0
  250. package/src/packages/tsconfig/node.json +11 -0
  251. package/src/packages/tsconfig/package.json +10 -0
  252. package/turbo.json +25 -0
@@ -0,0 +1,1730 @@
1
+ // ── Kanban App — Native Page Module ───────────────────────────────────────────
2
+ // ES module: mount(container, ctx), unmount(container), onProjectChange(project)
3
+ //
4
+ // This replaces the iframe-based page module with a fully native implementation.
5
+ // All DOM queries are scoped to `_root` (the container).
6
+
7
+ import { escapeHtml, escapeAttr, normalizeEscapes } from '/shared-assets/ui-utils.js';
8
+
9
+ let _root = null;
10
+ let _projectId = null;
11
+ let _navigate = null;
12
+
13
+ // ── Vendor library loading ───────────────────────────────────────────────────
14
+
15
+ let _vendorsReady = null;
16
+
17
+ function loadVendors() {
18
+ if (_vendorsReady) return _vendorsReady;
19
+ // Load sequentially: the AMD shim sets window.define=undefined then restores it
20
+ // in onload. Loading in parallel causes the second script to capture the already-
21
+ // cleared window.define, so when the first script's onload restores Monaco's AMD
22
+ // define, the second script executes with define present and registers via AMD
23
+ // instead of setting window.marked/window.Sortable.
24
+ _vendorsReady = loadScript('/app/kanban/vendor/sortable.min.js', 'Sortable')
25
+ .then(() => loadScript('/app/kanban/vendor/marked.min.js', 'marked'));
26
+ return _vendorsReady;
27
+ }
28
+
29
+ function loadScript(src, globalName) {
30
+ if (window[globalName]) return Promise.resolve();
31
+ return new Promise((resolve, reject) => {
32
+ const existing = document.querySelector(`script[src="${src}"]`);
33
+ if (existing) {
34
+ // Script tag exists — wait for global to appear (may still be loading)
35
+ if (window[globalName]) { resolve(); return; }
36
+ existing.addEventListener('load', resolve, { once: true });
37
+ existing.addEventListener('error', reject, { once: true });
38
+ return;
39
+ }
40
+ // Hide AMD define so UMD scripts don't conflict with Monaco's loader
41
+ const amdDefine = window.define;
42
+ window.define = undefined;
43
+ const s = document.createElement('script');
44
+ s.src = src;
45
+ s.onload = () => { window.define = amdDefine; resolve(); };
46
+ s.onerror = (e) => { window.define = amdDefine; reject(e); };
47
+ document.head.appendChild(s);
48
+ });
49
+ }
50
+
51
+ // ── API helpers ──────────────────────────────────────────────────────────────
52
+
53
+ function apiFetch(url, options = {}) {
54
+ const headers = { ...options.headers };
55
+ if (_projectId) headers['x-project-id'] = _projectId;
56
+ return fetch(url, { ...options, headers });
57
+ }
58
+
59
+ let _toastTimer = null;
60
+ function showToast(msg, type = 'error') {
61
+ if (!_root) return;
62
+ let toast = _root.querySelector('.toast');
63
+ if (!toast) {
64
+ toast = document.createElement('div');
65
+ toast.className = 'toast';
66
+ _root.appendChild(toast);
67
+ }
68
+ toast.textContent = msg;
69
+ toast.dataset.type = type;
70
+ toast.classList.add('visible');
71
+ clearTimeout(_toastTimer);
72
+ _toastTimer = setTimeout(() => toast.classList.remove('visible'), 4000);
73
+ }
74
+
75
+ // ── Scoped query helpers ─────────────────────────────────────────────────────
76
+
77
+ function $(sel) { return _root?.querySelector(sel) ?? null; }
78
+ function $$(sel) { return _root?.querySelectorAll(sel) ?? []; }
79
+
80
+ // ── Constants ────────────────────────────────────────────────────────────────
81
+
82
+ const FEATURE_COLORS = [
83
+ '#6366f1', '#8b5cf6', '#ec4899', '#ef4444',
84
+ '#f59e0b', '#22c55e', '#06b6d4', '#3b82f6',
85
+ ];
86
+
87
+ const PRIORITY_LABELS = { LOW: 'Low', MEDIUM: 'Medium', HIGH: 'High', URGENT: 'Urgent' };
88
+
89
+ // ── State ────────────────────────────────────────────────────────────────────
90
+
91
+ let features = [];
92
+ let pollTimer = null;
93
+ let boardFeature = null;
94
+ let boardPollTimer = null;
95
+ let isDragging = false;
96
+ let isDialogOpen = false;
97
+ let searchQuery = '';
98
+ let sortableInstances = [];
99
+ let selectedColor = FEATURE_COLORS[0];
100
+ let deleteTargetFeature = null;
101
+ let editTargetFeature = null;
102
+ let dialogState = null;
103
+ let pendingObjectURLs = [];
104
+ let _visibilityHandler = null;
105
+ let _searchKeydownHandler = null;
106
+ let _escapeHandler = null;
107
+ let _voiceHandler = null;
108
+
109
+ // ── Router (internal hash-based within the kanban page) ──────────────────────
110
+
111
+ function getRoute() {
112
+ const hash = location.hash || '#/';
113
+ const featureMatch = hash.match(/^#\/features\/(.+)$/);
114
+ if (featureMatch) return { page: 'board', featureId: featureMatch[1] };
115
+ return { page: 'list' };
116
+ }
117
+
118
+ function internalNavigate(hash) {
119
+ location.hash = hash;
120
+ }
121
+
122
+ // ── Feature list rendering ───────────────────────────────────────────────────
123
+
124
+ function renderFeatureList() {
125
+ if (!_root) return;
126
+ _root.innerHTML = `
127
+ <div class="sync-indicator" aria-live="polite">
128
+ <span class="sync-dot"></span>
129
+ Board updated
130
+ </div>
131
+ <header class="app-header">
132
+ <span class="app-name">Kanban</span>
133
+ <div class="header-actions">
134
+ <span class="sync-badge hidden" data-sync="list-sync">
135
+ <span class="sync-dot"></span> Updated
136
+ </span>
137
+ <button class="btn btn-primary" data-action="new-feature">+ New Feature</button>
138
+ </div>
139
+ </header>
140
+ <main class="features-container"></main>
141
+ ${_getDialogHTML()}
142
+ `;
143
+
144
+ $('[data-action="new-feature"]')?.addEventListener('click', openNewFeatureDialog);
145
+ _bindModalOverlays();
146
+ renderFeatures();
147
+ }
148
+
149
+ function renderFeatures() {
150
+ const main = $('.features-container');
151
+ if (!main) return;
152
+
153
+ if (features.length === 0) {
154
+ main.innerHTML = `
155
+ <div class="empty-state">
156
+ <div class="empty-icon">
157
+ <img src="/favicon.svg" alt="" style="width:28px;height:28px;opacity:0.5">
158
+ </div>
159
+ <p class="empty-text">No features yet</p>
160
+ <button class="btn btn-primary" data-action="empty-create">+ Create your first feature</button>
161
+ </div>
162
+ `;
163
+ $('[data-action="empty-create"]')?.addEventListener('click', openNewFeatureDialog);
164
+ return;
165
+ }
166
+
167
+ main.innerHTML = `
168
+ <h1 class="section-title">Features</h1>
169
+ <div class="features-grid">
170
+ ${features.map(f => `
171
+ <a href="#/features/${f.id}" class="feature-card" data-id="${f.id}">
172
+ <div class="feature-card-top">
173
+ <div class="feature-card-icon" style="background-color: ${f.color}30">
174
+ <div class="feature-card-dot" style="background-color: ${f.color}"></div>
175
+ </div>
176
+ <div class="feature-card-info">
177
+ <p class="feature-card-name">${escapeHtml(f.name)}</p>
178
+ <p class="feature-card-desc">${f.description ? escapeHtml(f.description) : ''}</p>
179
+ </div>
180
+ </div>
181
+ <div class="feature-card-footer">
182
+ <span class="feature-card-count"># ${f._count.issues} active item${f._count.issues !== 1 ? 's' : ''}</span>
183
+ <button class="feature-edit-btn" data-id="${f.id}" title="Edit feature" aria-label="Edit feature ${escapeHtml(f.name)}"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg></button>
184
+ <button class="feature-delete-btn" data-id="${f.id}" title="Delete feature" aria-label="Delete feature ${escapeHtml(f.name)}">&times;</button>
185
+ </div>
186
+ </a>
187
+ `).join('')}
188
+ </div>
189
+ `;
190
+
191
+ main.querySelectorAll('.feature-edit-btn').forEach(btn => {
192
+ btn.addEventListener('click', (e) => {
193
+ e.preventDefault();
194
+ e.stopPropagation();
195
+ const id = btn.dataset.id;
196
+ const feature = features.find(f => f.id === id);
197
+ if (feature) openEditFeatureDialog(feature);
198
+ });
199
+ });
200
+
201
+ main.querySelectorAll('.feature-delete-btn').forEach(btn => {
202
+ btn.addEventListener('click', (e) => {
203
+ e.preventDefault();
204
+ e.stopPropagation();
205
+ const id = btn.dataset.id;
206
+ const feature = features.find(f => f.id === id);
207
+ if (feature) openDeleteFeatureDialog(feature);
208
+ });
209
+ });
210
+ }
211
+
212
+ // ── Feature polling ──────────────────────────────────────────────────────────
213
+
214
+ function featureSignature(fs) {
215
+ return fs.map(f => `${f.id}:${f._count.issues}`).join('|');
216
+ }
217
+
218
+ function startFeaturePolling() {
219
+ pollTimer = setInterval(async () => {
220
+ try {
221
+ const res = await apiFetch('/api/kanban/features');
222
+ if (!res.ok) return;
223
+ const fresh = await res.json();
224
+ if (featureSignature(fresh) === featureSignature(features)) return;
225
+ features = fresh;
226
+ renderFeatures();
227
+ showSyncFlash('list-sync');
228
+ } catch { /* ignore */ }
229
+ }, 5000);
230
+ }
231
+
232
+ function stopPolling() {
233
+ if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
234
+ }
235
+
236
+ // ── Sync flash ───────────────────────────────────────────────────────────────
237
+
238
+ function showSyncFlash(name) {
239
+ const el = $(`[data-sync="${name}"]`);
240
+ if (!el) return;
241
+ el.classList.remove('hidden');
242
+ setTimeout(() => el.classList.add('hidden'), 1500);
243
+ }
244
+
245
+ // ── New Feature Dialog ───────────────────────────────────────────────────────
246
+
247
+ function openNewFeatureDialog() {
248
+ const dialog = $('.modal-overlay[data-dialog="new-feature"]');
249
+ if (!dialog) return;
250
+ const nameInput = dialog.querySelector('[data-field="nf-name"]');
251
+ const descInput = dialog.querySelector('[data-field="nf-desc"]');
252
+ const colorsDiv = dialog.querySelector('[data-field="nf-colors"]');
253
+
254
+ nameInput.value = '';
255
+ descInput.value = '';
256
+ selectedColor = FEATURE_COLORS[0];
257
+
258
+ colorsDiv.innerHTML = FEATURE_COLORS.map(c =>
259
+ `<button type="button" class="color-swatch ${c === selectedColor ? 'selected' : ''}"
260
+ data-color="${c}" style="background-color: ${c}" aria-label="Select color ${c}"></button>`
261
+ ).join('');
262
+
263
+ colorsDiv.querySelectorAll('.color-swatch').forEach(btn => {
264
+ btn.addEventListener('click', () => {
265
+ selectedColor = btn.dataset.color;
266
+ colorsDiv.querySelectorAll('.color-swatch').forEach(b => b.classList.remove('selected'));
267
+ btn.classList.add('selected');
268
+ });
269
+ });
270
+
271
+ dialog.classList.remove('hidden');
272
+ nameInput.focus();
273
+ }
274
+
275
+ function _bindNewFeatureDialog() {
276
+ const dialog = $('.modal-overlay[data-dialog="new-feature"]');
277
+ if (!dialog) return;
278
+
279
+ dialog.querySelector('[data-action="nf-cancel"]')?.addEventListener('click', () => {
280
+ dialog.classList.add('hidden');
281
+ });
282
+
283
+ dialog.querySelector('[data-action="nf-create"]')?.addEventListener('click', async () => {
284
+ const name = dialog.querySelector('[data-field="nf-name"]').value.trim();
285
+ if (!name) return;
286
+
287
+ const createBtn = dialog.querySelector('[data-action="nf-create"]');
288
+ createBtn.disabled = true;
289
+ createBtn.textContent = 'Creating...';
290
+
291
+ try {
292
+ const res = await apiFetch('/api/kanban/features', {
293
+ method: 'POST',
294
+ headers: { 'Content-Type': 'application/json' },
295
+ body: JSON.stringify({
296
+ name,
297
+ description: dialog.querySelector('[data-field="nf-desc"]').value,
298
+ color: selectedColor,
299
+ }),
300
+ });
301
+ const feature = await res.json();
302
+ features.unshift({ ...feature, description: feature.description || null, _count: { issues: 0 } });
303
+ renderFeatures();
304
+ dialog.classList.add('hidden');
305
+ } finally {
306
+ createBtn.disabled = false;
307
+ createBtn.textContent = 'Create Feature';
308
+ }
309
+ });
310
+
311
+ dialog.querySelector('[data-field="nf-name"]')?.addEventListener('keydown', (e) => {
312
+ if (e.key === 'Enter') dialog.querySelector('[data-action="nf-create"]')?.click();
313
+ });
314
+ }
315
+
316
+ // ── Delete Feature Dialog ────────────────────────────────────────────────────
317
+
318
+ function openDeleteFeatureDialog(feature) {
319
+ deleteTargetFeature = feature;
320
+ const dialog = $('.modal-overlay[data-dialog="delete-feature"]');
321
+ if (!dialog) return;
322
+ const msg = dialog.querySelector('[data-field="delete-feature-msg"]');
323
+ let text = `Are you sure you want to delete <strong>${escapeHtml(feature.name)}</strong>?`;
324
+ if (feature._count.issues > 0) {
325
+ text += ` This will permanently remove ${feature._count.issues} item${feature._count.issues !== 1 ? 's' : ''}.`;
326
+ }
327
+ msg.innerHTML = text;
328
+ dialog.classList.remove('hidden');
329
+ }
330
+
331
+ function _bindDeleteFeatureDialog() {
332
+ const dialog = $('.modal-overlay[data-dialog="delete-feature"]');
333
+ if (!dialog) return;
334
+
335
+ dialog.querySelector('[data-action="df-cancel"]')?.addEventListener('click', () => {
336
+ dialog.classList.add('hidden');
337
+ deleteTargetFeature = null;
338
+ });
339
+
340
+ dialog.querySelector('[data-action="df-confirm"]')?.addEventListener('click', async () => {
341
+ if (!deleteTargetFeature) return;
342
+ await apiFetch(`/api/kanban/features/${deleteTargetFeature.id}`, { method: 'DELETE' });
343
+ features = features.filter(f => f.id !== deleteTargetFeature.id);
344
+ renderFeatures();
345
+ dialog.classList.add('hidden');
346
+ deleteTargetFeature = null;
347
+ });
348
+ }
349
+
350
+ function _bindDeleteIssueDialog() {
351
+ const dialog = $('.modal-overlay[data-dialog="delete-issue"]');
352
+ if (!dialog) return;
353
+
354
+ dialog.querySelector('[data-action="di-cancel"]')?.addEventListener('click', () => {
355
+ dialog.classList.add('hidden');
356
+ $('.modal-overlay[data-dialog="issue"]')?.classList.remove('hidden');
357
+ });
358
+
359
+ dialog.querySelector('[data-action="di-confirm"]')?.addEventListener('click', async () => {
360
+ const s = dialogState;
361
+ if (!s.issue) return;
362
+ dialog.classList.add('hidden');
363
+ await apiFetch(`/api/kanban/issues/${s.issue.id}`, { method: 'DELETE' });
364
+ s.onDelete(s.issue.id);
365
+ closeDialog();
366
+ });
367
+
368
+ dialog.addEventListener('click', (e) => {
369
+ if (e.target === dialog) {
370
+ dialog.classList.add('hidden');
371
+ $('.modal-overlay[data-dialog="issue"]')?.classList.remove('hidden');
372
+ }
373
+ });
374
+ }
375
+
376
+ // ── Edit Feature Dialog ──────────────────────────────────────────────────────
377
+
378
+ function openEditFeatureDialog(feature) {
379
+ editTargetFeature = feature;
380
+ const dialog = $('.modal-overlay[data-dialog="edit-feature"]');
381
+ if (!dialog) return;
382
+
383
+ const nameInput = dialog.querySelector('[data-field="ef-name"]');
384
+ const descInput = dialog.querySelector('[data-field="ef-desc"]');
385
+ const colorsDiv = dialog.querySelector('[data-field="ef-colors"]');
386
+
387
+ nameInput.value = feature.name || '';
388
+ descInput.value = feature.description || '';
389
+ selectedColor = feature.color || FEATURE_COLORS[0];
390
+
391
+ colorsDiv.innerHTML = FEATURE_COLORS.map(c =>
392
+ `<button type="button" class="color-swatch ${c === selectedColor ? 'selected' : ''}"
393
+ data-color="${c}" style="background-color: ${c}" aria-label="Select color ${c}"></button>`
394
+ ).join('');
395
+
396
+ colorsDiv.querySelectorAll('.color-swatch').forEach(btn => {
397
+ btn.addEventListener('click', () => {
398
+ selectedColor = btn.dataset.color;
399
+ colorsDiv.querySelectorAll('.color-swatch').forEach(b => b.classList.remove('selected'));
400
+ btn.classList.add('selected');
401
+ });
402
+ });
403
+
404
+ dialog.classList.remove('hidden');
405
+ nameInput.focus();
406
+ }
407
+
408
+ function _bindEditFeatureDialog() {
409
+ const dialog = $('.modal-overlay[data-dialog="edit-feature"]');
410
+ if (!dialog) return;
411
+
412
+ dialog.querySelector('[data-action="ef-cancel"]')?.addEventListener('click', () => {
413
+ dialog.classList.add('hidden');
414
+ editTargetFeature = null;
415
+ });
416
+
417
+ dialog.querySelector('[data-action="ef-save"]')?.addEventListener('click', async () => {
418
+ if (!editTargetFeature) return;
419
+ const name = dialog.querySelector('[data-field="ef-name"]').value.trim();
420
+ if (!name) return;
421
+
422
+ const saveBtn = dialog.querySelector('[data-action="ef-save"]');
423
+ saveBtn.disabled = true;
424
+ saveBtn.textContent = 'Saving...';
425
+
426
+ try {
427
+ const res = await apiFetch(`/api/kanban/features/${editTargetFeature.id}`, {
428
+ method: 'PATCH',
429
+ headers: { 'Content-Type': 'application/json' },
430
+ body: JSON.stringify({
431
+ name,
432
+ description: dialog.querySelector('[data-field="ef-desc"]').value,
433
+ color: selectedColor,
434
+ }),
435
+ });
436
+ const updated = await res.json();
437
+
438
+ // Update in features list
439
+ const idx = features.findIndex(f => f.id === editTargetFeature.id);
440
+ if (idx !== -1) {
441
+ features[idx] = { ...features[idx], ...updated };
442
+ renderFeatures();
443
+ }
444
+
445
+ // Update board header if viewing this feature
446
+ if (boardFeature && boardFeature.id === editTargetFeature.id) {
447
+ boardFeature = { ...boardFeature, ...updated };
448
+ const nameEl = $('.app-name');
449
+ if (nameEl) nameEl.textContent = updated.name;
450
+ }
451
+
452
+ dialog.classList.add('hidden');
453
+ editTargetFeature = null;
454
+ } finally {
455
+ saveBtn.disabled = false;
456
+ saveBtn.textContent = 'Save Changes';
457
+ }
458
+ });
459
+
460
+ dialog.querySelector('[data-field="ef-name"]')?.addEventListener('keydown', (e) => {
461
+ if (e.key === 'Enter') dialog.querySelector('[data-action="ef-save"]')?.click();
462
+ });
463
+ }
464
+
465
+ // ── Modal overlays (close on click outside / Escape) ─────────────────────────
466
+
467
+ function _bindModalOverlays() {
468
+ $$('.modal-overlay').forEach(overlay => {
469
+ overlay.addEventListener('click', (e) => {
470
+ if (e.target === overlay) overlay.classList.add('hidden');
471
+ });
472
+ });
473
+
474
+ _bindNewFeatureDialog();
475
+ _bindDeleteFeatureDialog();
476
+ _bindDeleteIssueDialog();
477
+ _bindEditFeatureDialog();
478
+ }
479
+
480
+ // ── Kanban Board ─────────────────────────────────────────────────────────────
481
+
482
+ async function renderBoard(featureId) {
483
+ stopBoardPolling();
484
+ searchQuery = '';
485
+
486
+ if (!_root) return;
487
+ _root.innerHTML = '<div class="loading">Loading...</div>';
488
+
489
+ try {
490
+ const res = await apiFetch(`/api/kanban/features/${featureId}`);
491
+ if (!res.ok) { internalNavigate('#/'); return; }
492
+ boardFeature = await res.json();
493
+ } catch {
494
+ internalNavigate('#/');
495
+ return;
496
+ }
497
+
498
+ renderBoardUI();
499
+ startBoardPolling();
500
+ }
501
+
502
+ function renderBoardUI() {
503
+ if (!_root || !boardFeature) return;
504
+ const f = boardFeature;
505
+
506
+ _root.innerHTML = `
507
+ <div class="sync-indicator" aria-live="polite">
508
+ <span class="sync-dot"></span>
509
+ Board updated
510
+ </div>
511
+ <header class="app-header">
512
+ <a href="#/" class="back-btn" title="Back to features">&larr;</a>
513
+ <span class="app-name">${escapeHtml(f.name)}</span>
514
+ <button class="board-edit-btn" data-action="edit-board-feature" title="Edit feature"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg></button>
515
+ <div class="header-actions">
516
+ <span class="sync-badge hidden" data-sync="board-sync">
517
+ <span class="sync-dot"></span> Updated
518
+ </span>
519
+ </div>
520
+ </header>
521
+ <div class="board-search" role="search">
522
+ <div class="search-bar">
523
+ <input type="text" class="search-input" data-field="search-input" placeholder="Search issues... ( / )" value="${escapeHtml(searchQuery)}">
524
+ <button class="search-clear ${searchQuery ? '' : 'hidden'}" data-action="search-clear" aria-label="Clear search">&times;</button>
525
+ </div>
526
+ </div>
527
+ <div class="board-columns" data-region="board-columns">
528
+ ${getFilteredColumns().map(col => renderColumn(col)).join('')}
529
+ </div>
530
+ ${_getDialogHTML()}
531
+ `;
532
+
533
+ _bindModalOverlays();
534
+ $('[data-action="edit-board-feature"]')?.addEventListener('click', () => {
535
+ if (boardFeature) openEditFeatureDialog(boardFeature);
536
+ });
537
+ initSearch();
538
+ initSortable();
539
+ initAddIssueButtons();
540
+ initCardClicks();
541
+ }
542
+
543
+ function getFilteredColumns() {
544
+ if (!boardFeature) return [];
545
+ if (!searchQuery.trim()) return boardFeature.columns;
546
+ const q = searchQuery.toLowerCase();
547
+ return boardFeature.columns.map(col => ({
548
+ ...col,
549
+ issues: col.issues.filter(issue => {
550
+ let labels = [];
551
+ try { labels = JSON.parse(issue.labels || '[]'); } catch { labels = []; }
552
+ return (
553
+ issue.title.toLowerCase().includes(q) ||
554
+ (issue.description || '').toLowerCase().includes(q) ||
555
+ labels.some(l => l.toLowerCase().includes(q))
556
+ );
557
+ }),
558
+ }));
559
+ }
560
+
561
+ function renderColumn(col) {
562
+ const INTAKE = ['Backlog', 'Todo'];
563
+ const canAdd = INTAKE.includes(col.name);
564
+ return `
565
+ <div class="kanban-column" data-column-id="${col.id}">
566
+ <div class="column-header">
567
+ <span class="column-dot" style="background-color: ${col.color}"></span>
568
+ <span class="column-name">${escapeHtml(col.name)}</span>
569
+ <span class="column-count">${col.issues.length}</span>
570
+ </div>
571
+ <div class="column-drop-zone" data-column-id="${col.id}">
572
+ ${col.issues.map(issue => renderCard(issue)).join('')}
573
+ ${canAdd ? `<button class="add-issue-btn" data-column-id="${col.id}">+ Add issue</button>` : ''}
574
+ </div>
575
+ </div>
576
+ `;
577
+ }
578
+
579
+ function renderCard(issue) {
580
+ let labels = [];
581
+ try { labels = JSON.parse(issue.labels || '[]'); } catch { labels = []; }
582
+ const isOverdue = issue.dueDate && issue.dueDate.split('T')[0] < new Date().toISOString().split('T')[0];
583
+ const type = issue.type || 'TASK';
584
+ const priorityClass = `badge-${issue.priority.toLowerCase()}`;
585
+ const typeClass = type === 'BUG' ? 'badge-bug' : 'badge-secondary';
586
+
587
+ return `
588
+ <div class="issue-card" data-issue-id="${issue.id}" data-column-id="${issue.columnId}">
589
+ <p class="issue-card-title">${escapeHtml(issue.title)}</p>
590
+ <div class="issue-card-badges">
591
+ <span class="badge ${priorityClass}">${PRIORITY_LABELS[issue.priority]}</span>
592
+ <span class="badge ${typeClass}">${type === 'BUG' ? 'Bug' : 'Task'}</span>
593
+ ${labels.map(l => `<span class="badge badge-secondary">${escapeHtml(l)}</span>`).join('')}
594
+ ${issue.dueDate ? `<span class="badge ${isOverdue ? 'badge-due-overdue' : 'badge-due'}">${formatDate(issue.dueDate)}</span>` : ''}
595
+ ${issue.reviewCount > 0 ? `<span class="badge badge-review" title="Has review feedback">Review ${issue.reviewCount}</span>` : ''}
596
+ ${issue.workLogCount > 0 ? `<span class="badge badge-worklog" title="Has work log">Log ${issue.workLogCount}</span>` : ''}
597
+ </div>
598
+ </div>
599
+ `;
600
+ }
601
+
602
+ function formatDate(dateStr) {
603
+ return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
604
+ }
605
+
606
+ // ── Search ───────────────────────────────────────────────────────────────────
607
+
608
+ function initSearch() {
609
+ const input = $('[data-field="search-input"]');
610
+ const clear = $('[data-action="search-clear"]');
611
+ if (!input) return;
612
+
613
+ input.addEventListener('input', () => {
614
+ searchQuery = input.value;
615
+ clear?.classList.toggle('hidden', !searchQuery);
616
+ rerenderColumns();
617
+ });
618
+
619
+ clear?.addEventListener('click', () => {
620
+ searchQuery = '';
621
+ input.value = '';
622
+ clear.classList.add('hidden');
623
+ input.focus();
624
+ rerenderColumns();
625
+ });
626
+ }
627
+
628
+ function _handleSearchKeydown(e) {
629
+ const input = $('[data-field="search-input"]');
630
+ if (!input) return;
631
+
632
+ const action = typeof KeymapRegistry !== 'undefined' ? KeymapRegistry.resolve(e) : null;
633
+ const isFocusSearch = action === 'kanban:focus-search' || (!action && e.key === '/');
634
+ const isClearSearch = action === 'kanban:clear-search' || (!action && e.key === 'Escape');
635
+
636
+ if (isFocusSearch && !isDialogOpen &&
637
+ document.activeElement !== input &&
638
+ !(document.activeElement instanceof HTMLInputElement) &&
639
+ !(document.activeElement instanceof HTMLTextAreaElement)) {
640
+ e.preventDefault();
641
+ input.focus();
642
+ }
643
+ if (isClearSearch && document.activeElement === input) {
644
+ searchQuery = '';
645
+ input.value = '';
646
+ $('[data-action="search-clear"]')?.classList.add('hidden');
647
+ input.blur();
648
+ rerenderColumns();
649
+ }
650
+ }
651
+
652
+ function rerenderColumns() {
653
+ const container = $('[data-region="board-columns"]');
654
+ if (!container) return;
655
+ container.innerHTML = getFilteredColumns().map(col => renderColumn(col)).join('');
656
+ initSortable();
657
+ initAddIssueButtons();
658
+ initCardClicks();
659
+ }
660
+
661
+ // ── SortableJS drag-and-drop ─────────────────────────────────────────────────
662
+
663
+ function initSortable() {
664
+ sortableInstances.forEach(s => s.destroy());
665
+ sortableInstances = [];
666
+
667
+ $$('.column-drop-zone').forEach(zone => {
668
+ const sortable = new Sortable(zone, {
669
+ group: 'kanban',
670
+ animation: 150,
671
+ ghostClass: 'sortable-ghost',
672
+ chosenClass: 'sortable-chosen',
673
+ dragClass: 'sortable-drag',
674
+ filter: '.add-issue-btn',
675
+ draggable: '.issue-card',
676
+ onStart: () => { isDragging = true; },
677
+ onEnd: async (evt) => {
678
+ isDragging = false;
679
+ const issueId = evt.item.dataset.issueId;
680
+ const newColumnId = evt.to.dataset.columnId;
681
+ const newOrder = evt.newIndex;
682
+
683
+ updateLocalState(issueId, newColumnId, newOrder);
684
+
685
+ try {
686
+ const res = await apiFetch('/api/kanban/issues/reorder', {
687
+ method: 'POST',
688
+ headers: { 'Content-Type': 'application/json' },
689
+ body: JSON.stringify({ issueId, newColumnId, newOrder }),
690
+ });
691
+ if (!res.ok) throw new Error('reorder failed');
692
+ } catch {
693
+ try {
694
+ const res = await apiFetch(`/api/kanban/features/${boardFeature.id}`);
695
+ if (res.ok) {
696
+ boardFeature = await res.json();
697
+ rerenderColumns();
698
+ }
699
+ } catch { /* ignore */ }
700
+ }
701
+ },
702
+ });
703
+ sortableInstances.push(sortable);
704
+ });
705
+ }
706
+
707
+ function updateLocalState(issueId, newColumnId, newOrder) {
708
+ if (!boardFeature) return;
709
+ let movedIssue = null;
710
+
711
+ for (const col of boardFeature.columns) {
712
+ const idx = col.issues.findIndex(i => i.id === issueId);
713
+ if (idx >= 0) {
714
+ movedIssue = col.issues.splice(idx, 1)[0];
715
+ break;
716
+ }
717
+ }
718
+
719
+ if (!movedIssue) return;
720
+ movedIssue.columnId = newColumnId;
721
+
722
+ const targetCol = boardFeature.columns.find(c => c.id === newColumnId);
723
+ if (targetCol) {
724
+ targetCol.issues.splice(newOrder, 0, movedIssue);
725
+ }
726
+ }
727
+
728
+ // ── Add issue buttons ────────────────────────────────────────────────────────
729
+
730
+ function initAddIssueButtons() {
731
+ $$('.add-issue-btn').forEach(btn => {
732
+ btn.addEventListener('click', () => {
733
+ const columnId = btn.dataset.columnId;
734
+ isDialogOpen = true;
735
+ openIssueDialog({
736
+ issue: null,
737
+ columns: boardFeature.columns,
738
+ featureId: boardFeature.id,
739
+ defaultColumnId: columnId,
740
+ onSave: handleIssueSaved,
741
+ onDelete: handleIssueDeleted,
742
+ onClose: () => { isDialogOpen = false; },
743
+ });
744
+ });
745
+ });
746
+ }
747
+
748
+ // ── Card clicks (edit) ───────────────────────────────────────────────────────
749
+
750
+ function initCardClicks() {
751
+ $$('.issue-card').forEach(card => {
752
+ card.addEventListener('click', () => {
753
+ const issueId = card.dataset.issueId;
754
+ let issue = null;
755
+ for (const col of boardFeature.columns) {
756
+ issue = col.issues.find(i => i.id === issueId);
757
+ if (issue) break;
758
+ }
759
+ if (!issue) return;
760
+
761
+ isDialogOpen = true;
762
+ openIssueDialog({
763
+ issue,
764
+ columns: boardFeature.columns,
765
+ featureId: boardFeature.id,
766
+ defaultColumnId: null,
767
+ onSave: handleIssueSaved,
768
+ onDelete: handleIssueDeleted,
769
+ onClose: () => { isDialogOpen = false; },
770
+ });
771
+ });
772
+ });
773
+ }
774
+
775
+ // ── Issue save/delete callbacks ──────────────────────────────────────────────
776
+
777
+ function handleIssueSaved(savedIssue) {
778
+ if (!boardFeature) return;
779
+ for (const col of boardFeature.columns) {
780
+ col.issues = col.issues.filter(i => i.id !== savedIssue.id);
781
+ }
782
+ const targetCol = boardFeature.columns.find(c => c.id === savedIssue.columnId);
783
+ if (targetCol) {
784
+ targetCol.issues.push(savedIssue);
785
+ targetCol.issues.sort((a, b) => a.order - b.order);
786
+ }
787
+ rerenderColumns();
788
+ }
789
+
790
+ function handleIssueDeleted(issueId) {
791
+ if (!boardFeature) return;
792
+ for (const col of boardFeature.columns) {
793
+ col.issues = col.issues.filter(i => i.id !== issueId);
794
+ }
795
+ rerenderColumns();
796
+ }
797
+
798
+ // ── Board polling ────────────────────────────────────────────────────────────
799
+
800
+ function boardSignature() {
801
+ if (!boardFeature) return '';
802
+ return boardFeature.columns.flatMap(c =>
803
+ c.issues.map(i => `${i.id}:${i.columnId}:${i.order}:${i.updatedAt}`)
804
+ ).join('|');
805
+ }
806
+
807
+ function stopBoardPolling() {
808
+ if (boardPollTimer) { clearInterval(boardPollTimer); boardPollTimer = null; }
809
+ }
810
+
811
+ function startBoardPolling() {
812
+ boardPollTimer = setInterval(async () => {
813
+ if (isDragging || isDialogOpen) return;
814
+ try {
815
+ const res = await apiFetch(`/api/kanban/features/${boardFeature.id}`);
816
+ if (!res.ok) return;
817
+ const fresh = await res.json();
818
+ const freshSig = fresh.columns.flatMap(c =>
819
+ c.issues.map(i => `${i.id}:${i.columnId}:${i.order}:${i.updatedAt}`)
820
+ ).join('|');
821
+
822
+ if (freshSig === boardSignature()) return;
823
+ boardFeature = fresh;
824
+ rerenderColumns();
825
+ showSyncFlash('board-sync');
826
+ } catch { /* ignore */ }
827
+ }, 5000);
828
+ }
829
+
830
+ // ── Issue Dialog (create/edit) ───────────────────────────────────────────────
831
+
832
+ function revokePendingObjectURLs() {
833
+ pendingObjectURLs.forEach(url => URL.revokeObjectURL(url));
834
+ pendingObjectURLs = [];
835
+ }
836
+
837
+ function openIssueDialog({ issue, columns, featureId, defaultColumnId, onSave, onDelete, onClose }) {
838
+ revokePendingObjectURLs();
839
+
840
+ dialogState = {
841
+ issue,
842
+ columns,
843
+ featureId,
844
+ defaultColumnId,
845
+ onSave,
846
+ onDelete,
847
+ onClose,
848
+ title: issue?.title ?? '',
849
+ description: issue?.description ?? '',
850
+ priority: issue?.priority ?? 'MEDIUM',
851
+ columnId: issue?.columnId ?? defaultColumnId ?? columns[0]?.id ?? '',
852
+ type: issue?.type ?? 'TASK',
853
+ dueDate: issue?.dueDate ? new Date(issue.dueDate).toISOString().split('T')[0] : '',
854
+ labels: JSON.parse(issue?.labels ?? '[]'),
855
+ attachments: [],
856
+ pendingFiles: [],
857
+ previewMode: !!issue,
858
+ saving: false,
859
+ uploading: false,
860
+ workLog: [],
861
+ reviewHistory: [],
862
+ };
863
+
864
+ renderDialog();
865
+
866
+ if (issue?.id) {
867
+ apiFetch(`/api/kanban/issues/${issue.id}`)
868
+ .then(r => r.json())
869
+ .then(data => {
870
+ if (data.attachments) {
871
+ dialogState.attachments = data.attachments;
872
+ renderAttachments();
873
+ }
874
+ if (data.workLog) dialogState.workLog = data.workLog;
875
+ if (data.reviewHistory) dialogState.reviewHistory = data.reviewHistory;
876
+ renderVersionedEntries();
877
+ })
878
+ .catch(() => {});
879
+ }
880
+
881
+ const overlay = $('.modal-overlay[data-dialog="issue"]');
882
+ overlay?.classList.remove('hidden');
883
+ $('[data-field="dlg-title"]')?.focus();
884
+ }
885
+
886
+ function renderDialog() {
887
+ const s = dialogState;
888
+ if (!s) return;
889
+ const isEdit = !!s.issue;
890
+ const overlay = $('.modal-overlay[data-dialog="issue"]');
891
+ if (!overlay) return;
892
+ const modal = overlay.querySelector('.modal');
893
+
894
+ modal.innerHTML = `
895
+ <div class="modal-header">
896
+ <h2>${isEdit ? 'Edit Item' : 'New Item'}</h2>
897
+ <p class="modal-desc">${isEdit ? 'Update task or bug details.' : 'Add a new task or bug to the board.'}</p>
898
+ </div>
899
+ <div class="modal-body">
900
+ <div class="form-group">
901
+ <label>Title</label>
902
+ <input type="text" data-field="dlg-title" value="${escapeAttr(s.title)}" placeholder="Issue title...">
903
+ </div>
904
+
905
+ <div class="form-group">
906
+ <div class="description-header">
907
+ <label>Description</label>
908
+ <div class="description-tabs">
909
+ <button type="button" class="description-tab ${!s.previewMode ? 'active' : ''}" data-mode="write">Write</button>
910
+ <button type="button" class="description-tab ${s.previewMode ? 'active' : ''}" data-mode="preview">Preview</button>
911
+ </div>
912
+ </div>
913
+ <div data-region="dlg-desc-write" class="${s.previewMode ? 'hidden' : ''}">
914
+ <textarea data-field="dlg-desc" rows="8" placeholder="Add a description... (Markdown supported)">${escapeHtml(s.description)}</textarea>
915
+ </div>
916
+ <div data-region="dlg-desc-preview" class="markdown-preview ${!s.previewMode ? 'hidden' : ''}">
917
+ ${s.description ? marked.parse(normalizeEscapes(s.description)) : '<span class="text-muted">Nothing to preview</span>'}
918
+ </div>
919
+ </div>
920
+
921
+ ${isEdit ? `
922
+ <div class="form-group">
923
+ <div class="versioned-columns">
924
+ <div class="versioned-tab-content">
925
+ <div class="versioned-column-header">Review</div>
926
+ <div data-region="dlg-review-entries" class="versioned-entries"></div>
927
+ <textarea data-field="dlg-new-review" rows="3" placeholder="Add review feedback..."></textarea>
928
+ <button type="button" class="btn btn-secondary" data-action="dlg-add-review">+ Add Feedback</button>
929
+ </div>
930
+ <div class="versioned-tab-content">
931
+ <div class="versioned-column-header">Work Log</div>
932
+ <div data-region="dlg-worklog-entries" class="versioned-entries"></div>
933
+ <textarea data-field="dlg-new-worklog" rows="3" placeholder="Add work log entry..."></textarea>
934
+ <button type="button" class="btn btn-secondary" data-action="dlg-add-worklog">+ Add Entry</button>
935
+ </div>
936
+ </div>
937
+ </div>
938
+ ` : ''}
939
+
940
+ <div class="form-group">
941
+ <label>Attachments</label>
942
+ <div data-region="dlg-attachments" class="attachment-gallery"></div>
943
+ <div data-region="dlg-drop-zone" class="attachment-drop-zone">
944
+ <input type="file" data-field="dlg-file-input" accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml" multiple class="hidden">
945
+ <span>${s.uploading ? 'Uploading...' : 'Drop images here or click to upload'}</span>
946
+ </div>
947
+ </div>
948
+
949
+ <div class="field-row">
950
+ <div class="form-group">
951
+ <label>Priority</label>
952
+ <select data-field="dlg-priority">
953
+ ${Object.entries(PRIORITY_LABELS).map(([v, l]) =>
954
+ `<option value="${v}" ${s.priority === v ? 'selected' : ''}>${l}</option>`
955
+ ).join('')}
956
+ </select>
957
+ </div>
958
+ <div class="form-group">
959
+ <label>Column</label>
960
+ <select data-field="dlg-column">
961
+ ${s.columns.map(c =>
962
+ `<option value="${c.id}" ${s.columnId === c.id ? 'selected' : ''}>${escapeHtml(c.name)}</option>`
963
+ ).join('')}
964
+ </select>
965
+ </div>
966
+ <div class="form-group">
967
+ <label>Type</label>
968
+ <select data-field="dlg-type">
969
+ <option value="TASK" ${s.type === 'TASK' ? 'selected' : ''}>Task</option>
970
+ <option value="BUG" ${s.type === 'BUG' ? 'selected' : ''}>Bug</option>
971
+ </select>
972
+ </div>
973
+ </div>
974
+
975
+ <div class="form-group">
976
+ <label>Due Date</label>
977
+ <input type="date" data-field="dlg-due" value="${s.dueDate}" class="date-input">
978
+ </div>
979
+
980
+ <div class="form-group">
981
+ <label>Labels</label>
982
+ <div data-region="dlg-labels" class="labels-list">
983
+ ${s.labels.map((l, i) => `
984
+ <span class="label-badge">
985
+ ${escapeHtml(l)}
986
+ <button type="button" class="label-remove" data-index="${i}" aria-label="Remove label">&times;</button>
987
+ </span>
988
+ `).join('')}
989
+ </div>
990
+ <div class="label-input-row">
991
+ <input type="text" data-field="dlg-new-label" placeholder="Add label...">
992
+ <button type="button" class="btn btn-secondary" data-action="dlg-add-label">+</button>
993
+ </div>
994
+ </div>
995
+
996
+ </div>
997
+ <div class="modal-footer">
998
+ ${isEdit ? `<button class="btn btn-danger" data-action="dlg-delete">Delete</button>` : ''}
999
+ <div class="modal-footer-right">
1000
+ <button class="btn btn-secondary" data-action="dlg-cancel">Cancel</button>
1001
+ <button class="btn btn-primary" data-action="dlg-save" ${!s.title.trim() ? 'disabled' : ''}>
1002
+ ${s.saving ? 'Saving...' : (isEdit ? 'Save' : 'Create')}
1003
+ </button>
1004
+ </div>
1005
+ </div>
1006
+ `;
1007
+
1008
+ renderAttachments();
1009
+ bindDialogEvents();
1010
+ }
1011
+
1012
+ function renderAttachments() {
1013
+ const container = $('[data-region="dlg-attachments"]');
1014
+ if (!container || !dialogState) return;
1015
+
1016
+ const s = dialogState;
1017
+ let html = '';
1018
+
1019
+ s.attachments.forEach(att => {
1020
+ html += `
1021
+ <div class="attachment-thumb">
1022
+ <img src="/api/kanban/attachments/${att.id}" alt="${escapeAttr(att.filename)}" class="attachment-img" data-url="/api/kanban/attachments/${att.id}">
1023
+ <button type="button" class="attachment-thumb-delete" data-att-id="${att.id}" aria-label="Remove attachment">&times;</button>
1024
+ </div>
1025
+ `;
1026
+ });
1027
+
1028
+ revokePendingObjectURLs();
1029
+
1030
+ s.pendingFiles.forEach((file, i) => {
1031
+ const objUrl = URL.createObjectURL(file);
1032
+ pendingObjectURLs.push(objUrl);
1033
+ html += `
1034
+ <div class="attachment-thumb attachment-pending">
1035
+ <img src="${objUrl}" alt="${escapeAttr(file.name)}" class="attachment-img">
1036
+ <button type="button" class="attachment-thumb-delete" data-pending-index="${i}" aria-label="Remove pending attachment">&times;</button>
1037
+ </div>
1038
+ `;
1039
+ });
1040
+
1041
+ container.innerHTML = html;
1042
+
1043
+ // Image click -> preview
1044
+ container.querySelectorAll('.attachment-img').forEach(img => {
1045
+ img.addEventListener('click', () => {
1046
+ const url = img.dataset.url || img.src;
1047
+ const preview = $('.image-preview-overlay');
1048
+ if (!preview) return;
1049
+ preview.querySelector('img').src = url;
1050
+ preview.classList.remove('hidden');
1051
+ });
1052
+ });
1053
+
1054
+ // Delete server attachment
1055
+ container.querySelectorAll('[data-att-id]').forEach(btn => {
1056
+ btn.addEventListener('click', async (e) => {
1057
+ e.stopPropagation();
1058
+ const id = btn.dataset.attId;
1059
+ await apiFetch(`/api/kanban/attachments/${id}`, { method: 'DELETE' });
1060
+ s.attachments = s.attachments.filter(a => a.id !== id);
1061
+ renderAttachments();
1062
+ });
1063
+ });
1064
+
1065
+ // Delete pending file
1066
+ container.querySelectorAll('[data-pending-index]').forEach(btn => {
1067
+ btn.addEventListener('click', (e) => {
1068
+ e.stopPropagation();
1069
+ const idx = parseInt(btn.dataset.pendingIndex);
1070
+ s.pendingFiles.splice(idx, 1);
1071
+ renderAttachments();
1072
+ });
1073
+ });
1074
+ }
1075
+
1076
+ function bindDialogEvents() {
1077
+ const s = dialogState;
1078
+ if (!s) return;
1079
+
1080
+ // Title input
1081
+ const titleInput = $('[data-field="dlg-title"]');
1082
+ titleInput?.addEventListener('input', () => {
1083
+ s.title = titleInput.value;
1084
+ const saveBtn = $('[data-action="dlg-save"]');
1085
+ if (saveBtn) saveBtn.disabled = !s.title.trim();
1086
+ });
1087
+
1088
+ // Description write/preview tabs
1089
+ $$('.description-tab').forEach(tab => {
1090
+ tab.addEventListener('click', () => {
1091
+ const mode = tab.dataset.mode;
1092
+ s.previewMode = mode === 'preview';
1093
+ $$('.description-tab').forEach(t => t.classList.remove('active'));
1094
+ tab.classList.add('active');
1095
+
1096
+ const writeDiv = $('[data-region="dlg-desc-write"]');
1097
+ const previewDiv = $('[data-region="dlg-desc-preview"]');
1098
+
1099
+ if (s.previewMode) {
1100
+ s.description = $('[data-field="dlg-desc"]')?.value ?? s.description;
1101
+ writeDiv?.classList.add('hidden');
1102
+ previewDiv?.classList.remove('hidden');
1103
+ if (previewDiv) {
1104
+ previewDiv.innerHTML = s.description ? marked.parse(normalizeEscapes(s.description)) : '<span class="text-muted">Nothing to preview</span>';
1105
+ }
1106
+ } else {
1107
+ writeDiv?.classList.remove('hidden');
1108
+ previewDiv?.classList.add('hidden');
1109
+ }
1110
+ });
1111
+ });
1112
+
1113
+ // Description textarea
1114
+ $('[data-field="dlg-desc"]')?.addEventListener('input', (e) => {
1115
+ s.description = e.target.value;
1116
+ });
1117
+
1118
+ // Add review feedback
1119
+ $('[data-action="dlg-add-review"]')?.addEventListener('click', async () => {
1120
+ const textarea = $('[data-field="dlg-new-review"]');
1121
+ const content = textarea?.value.trim();
1122
+ if (!content || !s.issue?.id) return;
1123
+ const btn = $('[data-action="dlg-add-review"]');
1124
+ btn.disabled = true;
1125
+ btn.textContent = 'Adding...';
1126
+ try {
1127
+ const res = await apiFetch(`/api/kanban/issues/${s.issue.id}/review`, {
1128
+ method: 'POST',
1129
+ headers: { 'Content-Type': 'application/json' },
1130
+ body: JSON.stringify({ content }),
1131
+ });
1132
+ if (!res.ok) throw new Error(`Error: ${res.status}`);
1133
+ const entry = await res.json();
1134
+ s.reviewHistory.push(entry);
1135
+ textarea.value = '';
1136
+ renderVersionedEntries();
1137
+ } catch (err) {
1138
+ showToast('Failed to add review feedback: ' + err.message);
1139
+ } finally {
1140
+ btn.disabled = false;
1141
+ btn.textContent = '+ Add Feedback';
1142
+ }
1143
+ });
1144
+
1145
+ // Add work log entry
1146
+ $('[data-action="dlg-add-worklog"]')?.addEventListener('click', async () => {
1147
+ const textarea = $('[data-field="dlg-new-worklog"]');
1148
+ const content = textarea?.value.trim();
1149
+ if (!content || !s.issue?.id) return;
1150
+ const btn = $('[data-action="dlg-add-worklog"]');
1151
+ btn.disabled = true;
1152
+ btn.textContent = 'Adding...';
1153
+ try {
1154
+ const res = await apiFetch(`/api/kanban/issues/${s.issue.id}/work-log`, {
1155
+ method: 'POST',
1156
+ headers: { 'Content-Type': 'application/json' },
1157
+ body: JSON.stringify({ content }),
1158
+ });
1159
+ if (!res.ok) throw new Error(`Error: ${res.status}`);
1160
+ const entry = await res.json();
1161
+ s.workLog.push(entry);
1162
+ textarea.value = '';
1163
+ renderVersionedEntries();
1164
+ } catch (err) {
1165
+ showToast('Failed to add work log entry: ' + err.message);
1166
+ } finally {
1167
+ btn.disabled = false;
1168
+ btn.textContent = '+ Add Entry';
1169
+ }
1170
+ });
1171
+
1172
+ renderVersionedEntries();
1173
+
1174
+ // Priority, Column, Type selects
1175
+ $('[data-field="dlg-priority"]')?.addEventListener('change', (e) => { s.priority = e.target.value; });
1176
+ $('[data-field="dlg-column"]')?.addEventListener('change', (e) => { s.columnId = e.target.value; });
1177
+ $('[data-field="dlg-type"]')?.addEventListener('change', (e) => { s.type = e.target.value; });
1178
+
1179
+ // Due date
1180
+ $('[data-field="dlg-due"]')?.addEventListener('change', (e) => { s.dueDate = e.target.value; });
1181
+
1182
+ // Labels
1183
+ $('[data-action="dlg-add-label"]')?.addEventListener('click', addLabel);
1184
+ $('[data-field="dlg-new-label"]')?.addEventListener('keydown', (e) => {
1185
+ if (e.key === 'Enter') addLabel();
1186
+ });
1187
+ $$('.label-remove').forEach(btn => {
1188
+ btn.addEventListener('click', () => {
1189
+ s.labels.splice(parseInt(btn.dataset.index), 1);
1190
+ renderLabels();
1191
+ });
1192
+ });
1193
+
1194
+ // File upload
1195
+ const dropZone = $('[data-region="dlg-drop-zone"]');
1196
+ const fileInput = $('[data-field="dlg-file-input"]');
1197
+
1198
+ dropZone?.addEventListener('click', () => fileInput?.click());
1199
+ dropZone?.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('drag-over'); });
1200
+ dropZone?.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
1201
+ dropZone?.addEventListener('drop', (e) => {
1202
+ e.preventDefault();
1203
+ dropZone.classList.remove('drag-over');
1204
+ handleFiles(e.dataTransfer.files);
1205
+ });
1206
+ fileInput?.addEventListener('change', (e) => {
1207
+ handleFiles(e.target.files);
1208
+ e.target.value = '';
1209
+ });
1210
+
1211
+ // Save
1212
+ $('[data-action="dlg-save"]')?.addEventListener('click', handleSave);
1213
+
1214
+ // Cancel
1215
+ $('[data-action="dlg-cancel"]')?.addEventListener('click', closeDialog);
1216
+
1217
+ // Delete — show styled confirmation dialog
1218
+ $('[data-action="dlg-delete"]')?.addEventListener('click', () => {
1219
+ if (!s.issue) return;
1220
+ const dialog = $('.modal-overlay[data-dialog="delete-issue"]');
1221
+ if (!dialog) return;
1222
+ const msg = dialog.querySelector('[data-field="delete-issue-msg"]');
1223
+ if (msg) msg.innerHTML = `Are you sure you want to delete <strong>${escapeHtml(s.issue.title)}</strong>? This action cannot be undone.`;
1224
+ $('.modal-overlay[data-dialog="issue"]')?.classList.add('hidden');
1225
+ dialog.classList.remove('hidden');
1226
+ });
1227
+
1228
+ // Close on overlay click
1229
+ const overlay = $('.modal-overlay[data-dialog="issue"]');
1230
+ overlay?.addEventListener('click', (e) => {
1231
+ if (e.target === overlay) closeDialog();
1232
+ });
1233
+
1234
+ // Image preview close
1235
+ $('.image-preview-close')?.addEventListener('click', () => {
1236
+ $('.image-preview-overlay')?.classList.add('hidden');
1237
+ });
1238
+ $('.image-preview-overlay')?.addEventListener('click', (e) => {
1239
+ if (e.target.classList.contains('image-preview-overlay')) {
1240
+ e.target.classList.add('hidden');
1241
+ }
1242
+ });
1243
+ }
1244
+
1245
+ function addLabel() {
1246
+ const input = $('[data-field="dlg-new-label"]');
1247
+ const trimmed = input?.value.trim();
1248
+ if (trimmed && !dialogState.labels.includes(trimmed)) {
1249
+ dialogState.labels.push(trimmed);
1250
+ renderLabels();
1251
+ }
1252
+ if (input) input.value = '';
1253
+ }
1254
+
1255
+ function renderLabels() {
1256
+ const container = $('[data-region="dlg-labels"]');
1257
+ if (!container) return;
1258
+ container.innerHTML = dialogState.labels.map((l, i) => `
1259
+ <span class="label-badge">
1260
+ ${escapeHtml(l)}
1261
+ <button type="button" class="label-remove" data-index="${i}" aria-label="Remove label">&times;</button>
1262
+ </span>
1263
+ `).join('');
1264
+ container.querySelectorAll('.label-remove').forEach(btn => {
1265
+ btn.addEventListener('click', () => {
1266
+ dialogState.labels.splice(parseInt(btn.dataset.index), 1);
1267
+ renderLabels();
1268
+ });
1269
+ });
1270
+ }
1271
+
1272
+ async function handleFiles(fileList) {
1273
+ if (!fileList || fileList.length === 0) return;
1274
+ const files = Array.from(fileList);
1275
+ const s = dialogState;
1276
+
1277
+ if (s.issue?.id) {
1278
+ s.uploading = true;
1279
+ for (const f of files) {
1280
+ const form = new FormData();
1281
+ form.append('file', f);
1282
+ form.append('issueId', s.issue.id);
1283
+ const res = await apiFetch('/api/kanban/attachments', { method: 'POST', body: form });
1284
+ if (res.ok) {
1285
+ const att = await res.json();
1286
+ s.attachments.push(att);
1287
+ }
1288
+ }
1289
+ s.uploading = false;
1290
+ renderAttachments();
1291
+ } else {
1292
+ s.pendingFiles.push(...files);
1293
+ renderAttachments();
1294
+ }
1295
+ }
1296
+
1297
+ async function handleSave() {
1298
+ const s = dialogState;
1299
+ if (!s || !s.title.trim() || s.saving) return;
1300
+
1301
+ s.saving = true;
1302
+ const saveBtn = $('[data-action="dlg-save"]');
1303
+ if (saveBtn) {
1304
+ saveBtn.disabled = true;
1305
+ saveBtn.textContent = 'Saving...';
1306
+ }
1307
+
1308
+ if (!s.previewMode) {
1309
+ s.description = $('[data-field="dlg-desc"]')?.value ?? s.description;
1310
+ }
1311
+
1312
+ const data = {
1313
+ title: s.title.trim(),
1314
+ description: s.description || undefined,
1315
+ priority: s.priority,
1316
+ columnId: s.columnId,
1317
+ dueDate: s.dueDate ? new Date(s.dueDate).toISOString() : null,
1318
+ labels: JSON.stringify(s.labels),
1319
+ type: s.type,
1320
+ };
1321
+
1322
+ let savedIssue;
1323
+
1324
+ try {
1325
+ if (s.issue) {
1326
+ const res = await apiFetch(`/api/kanban/issues/${s.issue.id}`, {
1327
+ method: 'PATCH',
1328
+ headers: { 'Content-Type': 'application/json' },
1329
+ body: JSON.stringify(data),
1330
+ });
1331
+ if (!res.ok) throw new Error(`Server error: ${res.status}`);
1332
+ savedIssue = await res.json();
1333
+ } else {
1334
+ const res = await apiFetch('/api/kanban/issues', {
1335
+ method: 'POST',
1336
+ headers: { 'Content-Type': 'application/json' },
1337
+ body: JSON.stringify({ ...data, featureId: s.featureId, columnId: s.columnId }),
1338
+ });
1339
+ if (!res.ok) throw new Error(`Server error: ${res.status}`);
1340
+ savedIssue = await res.json();
1341
+
1342
+ if (savedIssue.id && s.pendingFiles.length > 0) {
1343
+ for (const f of s.pendingFiles) {
1344
+ const form = new FormData();
1345
+ form.append('file', f);
1346
+ form.append('issueId', savedIssue.id);
1347
+ await apiFetch('/api/kanban/attachments', { method: 'POST', body: form });
1348
+ }
1349
+ }
1350
+ }
1351
+
1352
+ s.saving = false;
1353
+ s.onSave(savedIssue);
1354
+ closeDialog();
1355
+ } catch (err) {
1356
+ s.saving = false;
1357
+ if (saveBtn) {
1358
+ saveBtn.disabled = false;
1359
+ saveBtn.textContent = s.issue ? 'Save' : 'Create';
1360
+ }
1361
+ showToast('Failed to save issue: ' + err.message);
1362
+ }
1363
+ }
1364
+
1365
+ function closeDialog() {
1366
+ revokePendingObjectURLs();
1367
+ $('.modal-overlay[data-dialog="issue"]')?.classList.add('hidden');
1368
+ if (dialogState?.onClose) dialogState.onClose();
1369
+ dialogState = null;
1370
+ }
1371
+
1372
+ function formatEntryDate(dateStr) {
1373
+ const d = new Date(dateStr);
1374
+ const now = new Date();
1375
+ const diff = (now - d) / 1000;
1376
+ if (diff < 60) return 'just now';
1377
+ if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
1378
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
1379
+ return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
1380
+ }
1381
+
1382
+ function renderVersionedEntries() {
1383
+ if (!dialogState) return;
1384
+ const s = dialogState;
1385
+
1386
+ const renderList = (entries, selector) => {
1387
+ const container = $(selector);
1388
+ if (!container) return;
1389
+ if (entries.length === 0) {
1390
+ container.innerHTML = '<div class="versioned-entries-empty">No entries yet</div>';
1391
+ return;
1392
+ }
1393
+ container.innerHTML = [...entries].reverse().map(e => `
1394
+ <div class="versioned-entry">
1395
+ <div class="versioned-entry-header">
1396
+ <span class="badge badge-secondary">v${e.version}</span>
1397
+ <span class="versioned-entry-date">${formatEntryDate(e.createdAt)}</span>
1398
+ </div>
1399
+ <div class="versioned-entry-content markdown-preview">${marked.parse(normalizeEscapes(e.content))}</div>
1400
+ </div>
1401
+ `).join('');
1402
+ };
1403
+
1404
+ renderList(s.reviewHistory, '[data-region="dlg-review-entries"]');
1405
+ renderList(s.workLog, '[data-region="dlg-worklog-entries"]');
1406
+ }
1407
+
1408
+ // ── Shared Dialog HTML ───────────────────────────────────────────────────────
1409
+
1410
+ function _getDialogHTML() {
1411
+ return `
1412
+ <!-- New Feature Dialog -->
1413
+ <div class="modal-overlay hidden" data-dialog="new-feature" role="dialog" aria-modal="true">
1414
+ <div class="modal">
1415
+ <div class="modal-header">
1416
+ <h2>New Feature</h2>
1417
+ <p class="modal-desc">Create a new feature with a kanban board.</p>
1418
+ </div>
1419
+ <div class="modal-body">
1420
+ <div class="form-group">
1421
+ <label>Name</label>
1422
+ <input type="text" data-field="nf-name" placeholder="Feature name...">
1423
+ </div>
1424
+ <div class="form-group">
1425
+ <label>Description</label>
1426
+ <textarea data-field="nf-desc" placeholder="Optional description..." rows="2"></textarea>
1427
+ </div>
1428
+ <div class="form-group">
1429
+ <label>Color</label>
1430
+ <div data-field="nf-colors" class="color-picker"></div>
1431
+ </div>
1432
+ <div class="modal-actions">
1433
+ <button class="btn btn-secondary" data-action="nf-cancel">Cancel</button>
1434
+ <button class="btn btn-primary" data-action="nf-create">Create Feature</button>
1435
+ </div>
1436
+ </div>
1437
+ </div>
1438
+ </div>
1439
+
1440
+ <!-- Delete Feature Confirmation Dialog -->
1441
+ <div class="modal-overlay hidden" data-dialog="delete-feature" role="dialog" aria-modal="true">
1442
+ <div class="modal">
1443
+ <div class="modal-header">
1444
+ <h2>Delete Feature</h2>
1445
+ <p class="modal-desc" data-field="delete-feature-msg"></p>
1446
+ </div>
1447
+ <div class="modal-actions">
1448
+ <button class="btn btn-secondary" data-action="df-cancel">Cancel</button>
1449
+ <button class="btn btn-danger" data-action="df-confirm">Delete</button>
1450
+ </div>
1451
+ </div>
1452
+ </div>
1453
+
1454
+ <!-- Delete Issue Confirmation Dialog -->
1455
+ <div class="modal-overlay hidden" data-dialog="delete-issue" role="dialog" aria-modal="true">
1456
+ <div class="modal">
1457
+ <div class="modal-header">
1458
+ <h2>Delete Item</h2>
1459
+ <p class="modal-desc" data-field="delete-issue-msg"></p>
1460
+ </div>
1461
+ <div class="modal-actions">
1462
+ <button class="btn btn-secondary" data-action="di-cancel">Cancel</button>
1463
+ <button class="btn btn-danger" data-action="di-confirm">Delete</button>
1464
+ </div>
1465
+ </div>
1466
+ </div>
1467
+
1468
+ <!-- Edit Feature Dialog -->
1469
+ <div class="modal-overlay hidden" data-dialog="edit-feature" role="dialog" aria-modal="true">
1470
+ <div class="modal">
1471
+ <div class="modal-header">
1472
+ <h2>Edit Feature</h2>
1473
+ <p class="modal-desc">Update feature details.</p>
1474
+ </div>
1475
+ <div class="modal-body">
1476
+ <div class="form-group">
1477
+ <label>Name</label>
1478
+ <input type="text" data-field="ef-name" placeholder="Feature name...">
1479
+ </div>
1480
+ <div class="form-group">
1481
+ <label>Description</label>
1482
+ <textarea data-field="ef-desc" placeholder="Optional description..." rows="6"></textarea>
1483
+ </div>
1484
+ <div class="form-group">
1485
+ <label>Color</label>
1486
+ <div data-field="ef-colors" class="color-picker"></div>
1487
+ </div>
1488
+ <div class="modal-actions">
1489
+ <button class="btn btn-secondary" data-action="ef-cancel">Cancel</button>
1490
+ <button class="btn btn-primary" data-action="ef-save">Save Changes</button>
1491
+ </div>
1492
+ </div>
1493
+ </div>
1494
+ </div>
1495
+
1496
+ <!-- Issue Dialog (create/edit) -->
1497
+ <div class="modal-overlay hidden" data-dialog="issue" role="dialog" aria-modal="true">
1498
+ <div class="modal modal-lg">
1499
+ <!-- Populated by renderDialog() -->
1500
+ </div>
1501
+ </div>
1502
+
1503
+ <!-- Image Preview Overlay -->
1504
+ <div class="image-preview-overlay hidden">
1505
+ <img src="" alt="Preview">
1506
+ <button class="image-preview-close" aria-label="Close image preview">&times;</button>
1507
+ </div>
1508
+ `;
1509
+ }
1510
+
1511
+ // ── Route handler ────────────────────────────────────────────────────────────
1512
+
1513
+ let _hashHandler = null;
1514
+
1515
+ async function onRouteChange() {
1516
+ if (!_root) return;
1517
+ const route = getRoute();
1518
+ stopPolling();
1519
+ stopBoardPolling();
1520
+
1521
+ if (route.page === 'board') {
1522
+ await renderBoard(route.featureId);
1523
+ } else {
1524
+ renderFeatureList();
1525
+ startFeaturePolling();
1526
+ }
1527
+ }
1528
+
1529
+ // ── Visibility change handler ────────────────────────────────────────────────
1530
+
1531
+ function _handleVisibility() {
1532
+ if (document.hidden) {
1533
+ stopPolling();
1534
+ stopBoardPolling();
1535
+ } else {
1536
+ const route = getRoute();
1537
+ if (route.page === 'list') {
1538
+ startFeaturePolling();
1539
+ } else if (route.page === 'board' && boardFeature) {
1540
+ startBoardPolling();
1541
+ }
1542
+ }
1543
+ }
1544
+
1545
+ // ── Voice input handler ──────────────────────────────────────────────────────
1546
+
1547
+ function _handleVoiceResult(e) {
1548
+ if (!_root) return;
1549
+ const text = e.detail?.text;
1550
+ if (!text) return;
1551
+
1552
+ // Insert voice text into the currently focused input/textarea within the kanban container
1553
+ const active = document.activeElement;
1554
+ if (active && _root.contains(active) && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) {
1555
+ const start = active.selectionStart ?? active.value.length;
1556
+ const end = active.selectionEnd ?? active.value.length;
1557
+ active.value = active.value.slice(0, start) + text + active.value.slice(end);
1558
+ active.selectionStart = active.selectionEnd = start + text.length;
1559
+ active.dispatchEvent(new Event('input', { bubbles: true }));
1560
+ }
1561
+ }
1562
+
1563
+ // ── Escape key handler (close modals) ────────────────────────────────────────
1564
+
1565
+ function _handleEscape(e) {
1566
+ if (e.key === 'Escape') {
1567
+ $$('.modal-overlay:not(.hidden)').forEach(m => m.classList.add('hidden'));
1568
+ const imgPreview = $('.image-preview-overlay:not(.hidden)');
1569
+ if (imgPreview) imgPreview.classList.add('hidden');
1570
+ }
1571
+ }
1572
+
1573
+ // ── Exports ──────────────────────────────────────────────────────────────────
1574
+
1575
+ export async function mount(container, ctx) {
1576
+ _root = container;
1577
+ _projectId = ctx?.project?.id || null;
1578
+ _navigate = ctx?.navigate || null;
1579
+
1580
+ // 1. Scope the container
1581
+ container.classList.add('page-kanban');
1582
+
1583
+ // 2. Show loading state while vendors load
1584
+ container.innerHTML = '<div class="loading">Loading...</div>';
1585
+
1586
+ // 3. Load vendor libraries (SortableJS + marked.js)
1587
+ await loadVendors();
1588
+
1589
+ // 4. Configure marked to escape raw HTML
1590
+ if (typeof marked !== 'undefined' && marked.use) {
1591
+ marked.use({
1592
+ breaks: true,
1593
+ renderer: {
1594
+ html({ text }) {
1595
+ return escapeHtml(text);
1596
+ },
1597
+ },
1598
+ });
1599
+ }
1600
+
1601
+ // 5. Reset state
1602
+ features = [];
1603
+ boardFeature = null;
1604
+ searchQuery = '';
1605
+ isDragging = false;
1606
+ isDialogOpen = false;
1607
+ dialogState = null;
1608
+ sortableInstances = [];
1609
+ pendingObjectURLs = [];
1610
+
1611
+ // 6. Fetch initial features
1612
+ try {
1613
+ const res = await apiFetch('/api/kanban/features');
1614
+ features = await res.json();
1615
+ } catch { features = []; }
1616
+
1617
+ // 7. Bind global event listeners
1618
+ _hashHandler = onRouteChange;
1619
+ window.addEventListener('hashchange', _hashHandler);
1620
+
1621
+ _visibilityHandler = _handleVisibility;
1622
+ document.addEventListener('visibilitychange', _visibilityHandler);
1623
+
1624
+ _searchKeydownHandler = _handleSearchKeydown;
1625
+ document.addEventListener('keydown', _searchKeydownHandler);
1626
+
1627
+ _escapeHandler = _handleEscape;
1628
+ document.addEventListener('keydown', _escapeHandler);
1629
+
1630
+ _voiceHandler = _handleVoiceResult;
1631
+ document.addEventListener('voice:result', _voiceHandler);
1632
+
1633
+ // 8. Render based on current route
1634
+ await onRouteChange();
1635
+ }
1636
+
1637
+ export function unmount(container) {
1638
+ // 1. Stop all polling
1639
+ stopPolling();
1640
+ stopBoardPolling();
1641
+
1642
+ // 2. Clean up SortableJS instances
1643
+ sortableInstances.forEach(s => s.destroy());
1644
+ sortableInstances = [];
1645
+
1646
+ // 3. Revoke object URLs
1647
+ revokePendingObjectURLs();
1648
+
1649
+ // 4. Remove event listeners
1650
+ if (_hashHandler) {
1651
+ window.removeEventListener('hashchange', _hashHandler);
1652
+ _hashHandler = null;
1653
+ }
1654
+ if (_visibilityHandler) {
1655
+ document.removeEventListener('visibilitychange', _visibilityHandler);
1656
+ _visibilityHandler = null;
1657
+ }
1658
+ if (_searchKeydownHandler) {
1659
+ document.removeEventListener('keydown', _searchKeydownHandler);
1660
+ _searchKeydownHandler = null;
1661
+ }
1662
+ if (_escapeHandler) {
1663
+ document.removeEventListener('keydown', _escapeHandler);
1664
+ _escapeHandler = null;
1665
+ }
1666
+ if (_voiceHandler) {
1667
+ document.removeEventListener('voice:result', _voiceHandler);
1668
+ _voiceHandler = null;
1669
+ }
1670
+
1671
+ // 5. Clear container
1672
+ container.classList.remove('page-kanban');
1673
+ container.innerHTML = '';
1674
+
1675
+ // 6. Reset module state
1676
+ _root = null;
1677
+ _projectId = null;
1678
+ _navigate = null;
1679
+ features = [];
1680
+ boardFeature = null;
1681
+ dialogState = null;
1682
+ deleteTargetFeature = null;
1683
+ editTargetFeature = null;
1684
+ }
1685
+
1686
+ export function onProjectChange(project) {
1687
+ // 1. Stop all polling from the previous project context
1688
+ stopPolling();
1689
+ stopBoardPolling();
1690
+
1691
+ // 2. Update project ID before any fetches so apiFetch sends the new header
1692
+ _projectId = project?.id || null;
1693
+
1694
+ // 3. Clear stale state from the previous project immediately
1695
+ features = [];
1696
+ boardFeature = null;
1697
+ searchQuery = '';
1698
+ dialogState = null;
1699
+ deleteTargetFeature = null;
1700
+ editTargetFeature = null;
1701
+ sortableInstances.forEach(s => s.destroy());
1702
+ sortableInstances = [];
1703
+
1704
+ // 4. If currently on the board view, navigate back to list first.
1705
+ // Use location.replace so the hashchange handler fires synchronously
1706
+ // (internalNavigate would cause onRouteChange to race with our fetch below).
1707
+ if (getRoute().page === 'board') {
1708
+ // Temporarily remove the hash handler to avoid onRouteChange racing with us
1709
+ if (_hashHandler) window.removeEventListener('hashchange', _hashHandler);
1710
+ location.hash = '#/';
1711
+ if (_hashHandler) window.addEventListener('hashchange', _hashHandler);
1712
+ }
1713
+
1714
+ // 5. Render the empty list view immediately (shows "No features yet" placeholder)
1715
+ renderFeatureList();
1716
+
1717
+ // 6. Fetch fresh features for the new project context
1718
+ apiFetch('/api/kanban/features')
1719
+ .then(r => r.json())
1720
+ .then(fresh => {
1721
+ features = fresh;
1722
+ renderFeatures();
1723
+ startFeaturePolling();
1724
+ })
1725
+ .catch(() => {
1726
+ features = [];
1727
+ renderFeatures();
1728
+ startFeaturePolling();
1729
+ });
1730
+ }