mdboard 1.3.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/bin.js +117 -59
  2. package/index.html +2161 -1579
  3. package/package.json +7 -5
  4. package/presets/kanban/api.json +91 -0
  5. package/presets/kanban/cli.json +69 -0
  6. package/presets/kanban/docs.json +29 -0
  7. package/presets/kanban/entities.json +128 -0
  8. package/presets/kanban/structure.json +15 -0
  9. package/presets/kanban/ui.json +86 -0
  10. package/presets/scrum/api.json +98 -0
  11. package/presets/scrum/cli.json +120 -0
  12. package/presets/scrum/docs.json +43 -0
  13. package/presets/scrum/entities.json +268 -0
  14. package/presets/scrum/structure.json +32 -0
  15. package/presets/scrum/ui.json +201 -0
  16. package/presets/shape-up/api.json +40 -0
  17. package/presets/shape-up/cli.json +44 -0
  18. package/presets/shape-up/docs.json +32 -0
  19. package/presets/shape-up/entities.json +140 -0
  20. package/presets/shape-up/structure.json +28 -0
  21. package/presets/shape-up/ui.json +114 -0
  22. package/src/cli/cli.js +186 -210
  23. package/src/cli/config.js +234 -0
  24. package/src/cli/init.js +128 -76
  25. package/src/cli/preset.js +849 -0
  26. package/src/cli/skill.js +417 -0
  27. package/src/cli/status.js +126 -96
  28. package/src/core/config.js +491 -38
  29. package/src/core/history.js +17 -1
  30. package/src/core/scanner.js +373 -463
  31. package/src/core/workspace.js +0 -15
  32. package/src/server/api.js +464 -741
  33. package/src/server/server.js +105 -130
  34. package/build.js +0 -44
  35. package/defaults.json +0 -43
  36. package/src/cli/sync.js +0 -194
  37. package/src/cli/theme.js +0 -142
  38. package/src/client/app.js +0 -266
  39. package/src/client/board.js +0 -157
  40. package/src/client/core.js +0 -331
  41. package/src/client/editor.js +0 -318
  42. package/src/client/history.js +0 -137
  43. package/src/client/metrics.js +0 -38
  44. package/src/client/milestones.js +0 -77
  45. package/src/client/notes.js +0 -183
  46. package/src/client/overview.js +0 -104
  47. package/src/client/panel.js +0 -637
  48. package/src/client/styles.css +0 -471
  49. package/src/client/table.js +0 -111
  50. package/src/client/template.html +0 -144
  51. package/src/client/themes.js +0 -261
  52. package/src/client/workspace.js +0 -164
  53. package/src/core/agent-scanner.js +0 -260
package/index.html CHANGED
@@ -67,31 +67,45 @@ a{color:var(--accent);text-decoration:none}
67
67
  .stat-val{font-size:20px;font-weight:700;font-family:var(--mono);line-height:1.2}
68
68
  .stat-label{font-size:10px;text-transform:uppercase;letter-spacing:.04em;color:var(--text3);font-weight:500}
69
69
 
70
+ /* ── Global Search ───────────────────────────────────────── */
71
+ .global-search-wrap{position:relative;flex:1;max-width:400px}
72
+ .global-search-icon{position:absolute;left:10px;top:50%;transform:translateY(-50%);width:16px;height:16px;color:var(--text3);pointer-events:none}
73
+ .global-search-input{width:100%;padding:8px 12px 8px 34px;font-size:13px;border:1px solid var(--border);border-radius:8px;background:var(--surface2);color:var(--text);outline:none;font-family:inherit;transition:border-color .2s,box-shadow .2s}
74
+ .global-search-input::placeholder{color:var(--text3)}
75
+ .global-search-input:focus{border-color:var(--accent);box-shadow:0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent)}
76
+ .global-search-results{display:none;position:absolute;top:calc(100% + 4px);left:0;right:0;background:var(--surface);border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.3);max-height:360px;overflow-y:auto;z-index:100}
77
+ .gs-item{display:flex;align-items:center;gap:8px;padding:8px 12px;cursor:pointer;font-size:13px;transition:background .1s}
78
+ .gs-item:hover,.gs-item.active{background:var(--surface2)}
79
+ .gs-item .icon{width:16px;height:16px;flex-shrink:0}
80
+ .gs-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}
81
+ .gs-meta{font-size:11px;color:var(--text3);font-family:var(--mono);flex-shrink:0}
82
+ .gs-type{font-size:10px;text-transform:uppercase;letter-spacing:.04em;color:var(--text3);background:var(--surface2);padding:2px 6px;border-radius:4px;flex-shrink:0}
83
+ .gs-empty{padding:16px;text-align:center;color:var(--text3);font-size:13px}
84
+
70
85
  /* ── Content ─────────────────────────────────────────────── */
71
86
  .content{flex:1;overflow-y:auto;padding:20px 24px}
72
87
  .view{display:none}
73
88
  .view.active{display:block}
74
- #view-notes.active{display:flex;flex-direction:column;margin:-20px -24px;height:calc(100% + 40px)}
75
- #view-notes #notes-container{flex:1;overflow:hidden}
76
89
 
77
90
  /* ── Icons ───────────────────────────────────────────────── */
78
91
  .icon{width:16px;height:16px;flex-shrink:0;vertical-align:middle;display:inline-block}
79
92
  .icon-sm{width:14px;height:14px}
80
93
 
81
- /* ── Filters ─────────────────────────────────────────────── */
82
- .filter-bar{display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;align-items:center}
83
- .filter-bar input,.filter-bar select{background:var(--surface);border:1px solid var(--border);color:var(--text);padding:6px 10px;border-radius:var(--radius-sm);font-size:12px;font-family:var(--font)}
84
- .filter-bar input{min-width:160px;flex:1;max-width:280px}
85
- .filter-bar select{min-width:110px}
86
- .filter-bar input:focus,.filter-bar select:focus{outline:none;border-color:var(--accent)}
87
- .filter-bar label{font-size:11px;color:var(--text3);font-weight:600;text-transform:uppercase;letter-spacing:.04em}
94
+ /* ── View Toolbar & Filters ─────────────────────────────── */
95
+ .view-toolbar{display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;align-items:center}
96
+ .view-toolbar input,.view-toolbar select{background:var(--surface);border:1px solid var(--border);color:var(--text);padding:6px 10px;border-radius:var(--radius-sm);font-size:12px;font-family:var(--font)}
97
+ .view-toolbar input{min-width:160px;flex:1;max-width:280px}
98
+ .view-toolbar select{min-width:110px}
99
+ .view-toolbar input:focus,.view-toolbar select:focus{outline:none;border-color:var(--accent)}
100
+
101
+ /* ── Layout Switcher ────────────────────────────────────── */
102
+ .layout-switcher{display:flex;gap:2px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);padding:2px}
103
+ .layout-btn{padding:4px 12px;border:none;background:transparent;color:var(--text2);font-size:12px;font-weight:500;cursor:pointer;border-radius:var(--radius-sm);font-family:var(--font);transition:all .15s}
104
+ .layout-btn:hover{color:var(--text)}
105
+ .layout-btn.active{background:var(--accent-dim);color:var(--accent)}
88
106
 
89
107
  /* ── Board View ──────────────────────────────────────────── */
90
- #sprint-bar{display:flex;align-items:center;gap:20px;padding:12px 16px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:16px;font-size:13px}
91
- .sprint-goal{flex:1}
92
- .sprint-goal b{color:var(--text)}
93
- .sprint-dates{font-size:12px;color:var(--text2);font-family:var(--mono)}
94
- #board{display:flex;gap:12px;min-height:400px;overflow-x:auto;padding-bottom:16px}
108
+ .board-container{display:flex;gap:12px;min-height:400px;overflow-x:auto;padding-bottom:16px}
95
109
  .column{flex:1;min-width:220px;max-width:300px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);display:flex;flex-direction:column;transition:border-color .2s,background .2s}
96
110
  .column.drag-over{border-color:var(--accent);background:var(--accent-dim)}
97
111
  .column-header{padding:12px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;gap:8px}
@@ -114,7 +128,6 @@ a{color:var(--accent);text-decoration:none}
114
128
  .pill-points{background:var(--surface2);color:var(--text2);font-family:var(--mono)}
115
129
  .pill-epic{color:#fff;font-size:10px;font-weight:600}
116
130
  .badge{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:var(--radius-sm);font-size:11px;font-weight:600;text-transform:capitalize}
117
- /* Fallback badge styles — overridden by dynamic styles once config loads */
118
131
  .badge-backlog{background:var(--surface2);color:var(--text2)}
119
132
  .badge-todo{background:var(--accent-dim);color:var(--accent)}
120
133
  .badge-in-progress{background:var(--warning-dim);color:var(--warning)}
@@ -139,32 +152,42 @@ a{color:var(--accent);text-decoration:none}
139
152
  th.sorted .sort-arrow{opacity:1;color:var(--accent)}
140
153
  .ftable td{padding:8px 12px;border-bottom:1px solid var(--border)}
141
154
  .ftable tr:hover td{background:var(--surface)}
142
- .ftable tr.expanded td{background:var(--surface);border-bottom-color:transparent}
143
155
  .td-id{font-family:var(--mono);font-size:12px;color:var(--text2);white-space:nowrap}
144
156
  .td-title{cursor:pointer;font-weight:500}
145
157
  .td-title:hover{color:var(--accent)}
146
- .detail-row td{background:var(--surface)!important;padding:16px 24px}
147
- .detail-content{font-size:13px;line-height:1.6}
148
- .detail-content h4{font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--text2);margin-bottom:8px}
149
- .detail-content ul{padding-left:20px;margin-top:4px}
150
- .detail-content li{margin-bottom:4px;color:var(--text2)}
151
158
  .clickable-row{cursor:pointer}
152
159
  .clickable-row:hover td{background:var(--accent-dim)!important}
153
160
 
154
- /* ── Milestones View ─────────────────────────────────────── */
155
- .ms-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:20px;margin-bottom:16px;cursor:pointer;transition:border-color .15s}
156
- .ms-card:hover{border-color:var(--border2)}
157
- .ms-header{display:flex;align-items:center;gap:12px;margin-bottom:16px}
158
- .ms-header h2{font-size:16px;font-weight:700;display:flex;align-items:center;gap:8px}
159
- .ms-deadline{font-size:12px;color:var(--text2);font-family:var(--mono);margin-left:auto}
160
- .ms-progress{margin-bottom:16px}
161
- .ms-epics{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}
162
- .ms-epic{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);padding:12px;cursor:pointer;transition:border-color .15s}
163
- .ms-epic:hover{border-color:var(--border2)}
164
- .epic-name{font-weight:600;font-size:13px;margin-bottom:6px;display:flex;align-items:center;gap:6px}
165
- .dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
166
- .epic-counts{font-size:11px;color:var(--text2);margin-bottom:6px}
167
- .epic-meta{display:flex;align-items:center;gap:6px;margin-top:8px}
161
+ /* ── Cards View (new) ───────────────────────────────────── */
162
+ .cards-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:16px}
163
+ .entity-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px;cursor:pointer;transition:border-color .15s}
164
+ .entity-card:hover{border-color:var(--border2)}
165
+ .entity-card-header{display:flex;align-items:center;gap:8px;margin-bottom:8px}
166
+ .entity-card-id{font-family:var(--mono);font-size:11px;color:var(--text3)}
167
+ .entity-card-title{font-size:15px;font-weight:600;margin-bottom:10px;line-height:1.4}
168
+ .entity-card-meta{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px}
169
+ .entity-card-progress{margin-top:8px}
170
+ .entity-card-children{margin-top:12px;border-top:1px solid var(--border);padding-top:10px;display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:8px}
171
+ .child-card{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);padding:8px 10px;cursor:pointer;transition:border-color .15s}
172
+ .child-card:hover{border-color:var(--border2)}
173
+ .child-card-header{display:flex;align-items:center;gap:6px;margin-bottom:4px}
174
+ .child-card-title{font-size:12px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
175
+
176
+ /* ── List View (new) ────────────────────────────────────── */
177
+ .entity-list{display:flex;flex-direction:column;gap:2px}
178
+ .entity-list-item{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);padding:0;cursor:pointer;transition:border-color .15s}
179
+ .entity-list-item:hover{border-color:var(--border2)}
180
+ .list-item-main{display:flex;align-items:center;gap:10px;padding:12px 16px}
181
+ .list-item-status{flex-shrink:0}
182
+ .list-item-title{font-size:14px;font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
183
+ .list-item-fields{display:flex;gap:6px;align-items:center;flex-shrink:0}
184
+ .list-item-progress{display:flex;align-items:center;gap:6px}
185
+ .list-expand-btn{background:none;border:none;color:var(--text3);cursor:pointer;font-size:10px;padding:2px 4px;border-radius:var(--radius-sm);width:20px;flex-shrink:0}
186
+ .list-expand-btn:hover{color:var(--text);background:var(--surface2)}
187
+ .list-children{padding:4px 16px 12px 46px;display:flex;flex-direction:column;gap:4px}
188
+ .child-list-item{display:flex;align-items:center;gap:8px;padding:6px 10px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);cursor:pointer;font-size:13px;transition:border-color .15s}
189
+ .child-list-item:hover{border-color:var(--border2)}
190
+ .child-list-title{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
168
191
 
169
192
  /* ── Metrics View ────────────────────────────────────────── */
170
193
  .metrics-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px}
@@ -179,21 +202,17 @@ th.sorted .sort-arrow{opacity:1;color:var(--accent)}
179
202
  /* ── Detail Panel — Notion-style ─────────────────────────── */
180
203
  .panel-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:99;opacity:0;pointer-events:none;transition:opacity .2s}
181
204
  .panel-overlay.open{opacity:1;pointer-events:auto}
182
- .detail-panel{position:fixed;top:0;right:0;width:600px;max-width:92vw;height:100vh;background:var(--bg);border-left:1px solid var(--border);z-index:100;transform:translateX(100%);transition:transform .25s ease;display:flex;flex-direction:column;overflow:hidden}
205
+ .detail-panel{top: 1rem;right: 1rem;height: calc(100vh - 2rem);border-radius: var(--radius);position:fixed;width:600px;max-width:92vw;background:var(--bg);border-left:1px solid var(--border);z-index:100;transform:translateX(100%);transition:transform .25s ease;display:flex;flex-direction:column;overflow:hidden}
183
206
  .detail-panel.open{transform:translateX(0)}
184
207
  .panel-header{padding:12px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px;flex-shrink:0;background:var(--surface)}
185
208
  .panel-type{font-size:10px;padding:3px 8px;border-radius:var(--radius-sm);background:var(--accent-dim);color:var(--accent);font-weight:700;text-transform:uppercase;letter-spacing:.04em}
186
209
  .panel-item-id{font-family:var(--mono);font-size:13px;color:var(--text2)}
187
210
  .panel-close{margin-left:auto;background:none;border:none;color:var(--text2);cursor:pointer;font-size:18px;padding:4px 8px;border-radius:var(--radius-sm);line-height:1}
188
211
  .panel-close:hover{background:var(--surface2);color:var(--text)}
189
-
190
- /* Panel title — large Notion-style */
191
212
  .panel-title-wrap{padding:20px 24px 8px;flex-shrink:0}
192
213
  .panel-title-input{width:100%;font-size:28px;font-weight:700;background:transparent;border:none;color:var(--text);font-family:var(--font);outline:none;padding:0;line-height:1.3}
193
214
  .panel-title-input::placeholder{color:var(--text3)}
194
215
  .panel-title-input:disabled{opacity:.7}
195
-
196
- /* Panel properties — compact Notion-style rows */
197
216
  .panel-properties{padding:4px 24px 8px;flex-shrink:0;border-bottom:1px solid var(--border)}
198
217
  .prop-row{display:flex;align-items:center;padding:4px 0;min-height:32px}
199
218
  .prop-label{width:100px;flex-shrink:0;font-size:12px;color:var(--text3);font-weight:500}
@@ -204,31 +223,20 @@ th.sorted .sort-arrow{opacity:1;color:var(--accent)}
204
223
  .prop-value select:disabled,.prop-value input:disabled{opacity:.6;cursor:default}
205
224
  .prop-value select:disabled:hover,.prop-value input:disabled:hover{border-color:transparent}
206
225
  .prop-text{font-size:13px;color:var(--text2);padding:4px 8px}
207
-
208
- /* AI Tag Input */
209
- .prop-row-divider{margin-top:4px;border-top:1px solid var(--border);padding-top:8px}
226
+ .prop-longtext{width:100%}
227
+ .prop-longtext-preview{display:flex;align-items:center;gap:6px;cursor:pointer;padding:4px 8px;border-radius:var(--radius-sm);transition:background .15s;min-height:28px}
228
+ .prop-longtext-preview:hover{background:var(--surface2)}
229
+ .prop-longtext-text{flex:1;font-size:13px;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
230
+ .prop-longtext-toggle{font-size:9px;color:var(--text3);flex-shrink:0;transition:transform .15s}
231
+ .prop-longtext-editor{width:100%;min-height:80px;resize:vertical;background:var(--surface);border:1px solid var(--border);color:var(--text);padding:8px;border-radius:var(--radius-sm);font-family:var(--font);font-size:13px;line-height:1.5;outline:none;margin-top:4px;transition:border-color .2s}
232
+ .prop-longtext-editor:focus{border-color:var(--accent)}
233
+ .prop-longtext-editor:disabled{opacity:.6;cursor:default}
234
+ .prop-icon{flex-shrink:0}
210
235
  .prop-row-toggle{cursor:pointer;user-select:none}
211
236
  .prop-row-toggle:hover{background:var(--surface2);border-radius:var(--radius-sm)}
212
237
  .ai-toggle-arrow{display:inline-block;font-size:10px;width:14px;transition:transform .15s}
213
- .ai-tag-input{flex:1;display:flex;flex-wrap:wrap;align-items:center;gap:4px;padding:2px 4px;min-height:28px;border:1px solid transparent;border-radius:var(--radius-sm);cursor:text;position:relative;transition:border-color .15s}
214
- .ai-tag-input:hover{border-color:var(--border)}
215
- .ai-tag-input:focus-within{border-color:var(--accent);background:var(--surface)}
216
- .ai-tag{display:inline-flex;align-items:center;gap:2px;padding:1px 8px;border-radius:10px;font-size:11px;font-weight:500;white-space:nowrap;background:var(--surface2);color:var(--text2);border:1px solid var(--border)}
217
- .ai-tag-own{background:var(--accent-dim);color:var(--accent);border-color:var(--accent)}
218
- .ai-tag-inherited{border-style:dashed;opacity:.6}
219
- .ai-tag-remove{background:none;border:none;color:inherit;cursor:pointer;font-size:13px;line-height:1;padding:0 0 0 2px;opacity:.6}
220
- .ai-tag-remove:hover{opacity:1}
221
- .ai-tag-text{border:none!important;background:transparent!important;padding:2px 4px!important;font-size:12px;min-width:60px;flex:1;outline:none;color:var(--text)}
222
- .ai-tag-text::placeholder{color:var(--text3);font-size:11px}
223
- .ai-autocomplete{position:absolute;top:100%;left:0;right:0;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);box-shadow:0 8px 24px rgba(0,0,0,.4);z-index:50;max-height:160px;overflow-y:auto;margin-top:2px;display:none}
224
- .ai-autocomplete.open{display:block}
225
- .ai-autocomplete-item{padding:6px 10px;font-size:12px;color:var(--text2);cursor:pointer;transition:background .1s}
226
- .ai-autocomplete-item:hover,.ai-autocomplete-item.active{background:var(--accent-dim);color:var(--text)}
227
-
228
- /* Panel content area — fills remaining space */
229
238
  .panel-content-area{flex:1;overflow-y:auto;padding:0}
230
239
  .panel-editor-zone{padding:16px 24px;min-height:200px}
231
-
232
240
  .panel-footer{padding:12px 20px;border-top:1px solid var(--border);display:flex;gap:8px;align-items:center;flex-shrink:0}
233
241
  .btn{padding:8px 16px;border-radius:var(--radius-sm);font-size:13px;font-weight:500;cursor:pointer;border:1px solid var(--border);background:var(--surface2);color:var(--text);transition:all .15s;font-family:var(--font)}
234
242
  .btn:hover{background:var(--border)}
@@ -254,30 +262,13 @@ th.sorted .sort-arrow{opacity:1;color:var(--accent)}
254
262
  .loading-container{padding:20px}
255
263
  @keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
256
264
 
257
- /* ── Workspace: Source Icon (reused in header chips) ──────── */
265
+ /* ── Workspace: Source Icon ──────────────────────────────── */
258
266
  .source-icon{width:20px;height:20px;border-radius:var(--radius-sm);display:flex;align-items:center;justify-content:center;font-size:12px;flex-shrink:0;font-weight:700;line-height:1}
259
267
 
260
- /* ── Link Chips ──────────────────────────────────────────── */
261
- .link-chip{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:500;cursor:pointer;border:1px solid var(--border);background:var(--surface2);color:var(--text2);transition:all .15s;white-space:nowrap}
262
- .link-chip:hover{background:var(--border);color:var(--text)}
263
- .link-chip-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}
264
- .link-chips{display:flex;flex-wrap:wrap;gap:4px;margin-top:4px}
265
-
266
268
  /* ── CRUD Buttons ────────────────────────────────────────── */
267
269
  .btn-sm{padding:5px 12px;font-size:12px;border-radius:var(--radius-sm)}
268
270
  .btn-create{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600}
269
271
  .btn-create:hover{opacity:.9}
270
- .card-readonly{opacity:.75;cursor:default}
271
- .card-readonly:hover{border-color:var(--border);background:var(--bg)}
272
-
273
- /* ── Overview: Tracked Milestones ────────────────────────── */
274
- .tracked-ms{margin-top:12px;padding:12px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm)}
275
- .tracked-ms-header{font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--text3);font-weight:600;margin-bottom:8px}
276
- .tracked-ms-item{display:flex;align-items:center;gap:8px;padding:4px 0}
277
- .tracked-ms-item .dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
278
- .tracked-ms-item .progress{flex:1;max-width:120px}
279
- .tracked-ms-item span{font-size:12px}
280
- .tracked-ms-pct{font-family:var(--mono);font-weight:600;font-size:12px;min-width:36px;text-align:right}
281
272
 
282
273
  /* ── Notes View — Notion-style ───────────────────────────── */
283
274
  .notes-layout{display:flex;height:100%;gap:0}
@@ -313,25 +304,21 @@ th.sorted .sort-arrow{opacity:1;color:var(--accent)}
313
304
  .codex-editor .ce-toolbar__settings-btn{color:var(--text3);background:transparent;width:26px;height:26px}
314
305
  .codex-editor .ce-toolbar__settings-btn:hover{background:var(--surface2);color:var(--text)}
315
306
  .codex-editor .ce-toolbar__actions{background:transparent}
316
- /* Inline toolbar */
317
307
  .codex-editor .ce-inline-toolbar{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 8px 24px rgba(0,0,0,.4)}
318
308
  .codex-editor .ce-inline-toolbar__buttons{color:var(--text)}
319
309
  .codex-editor .ce-inline-tool{color:var(--text2)}
320
310
  .codex-editor .ce-inline-tool:hover{color:var(--text);background:var(--surface2)}
321
311
  .codex-editor .ce-inline-tool--active{color:var(--accent)}
322
312
  .codex-editor .ce-inline-tool-input{background:var(--bg);color:var(--text);border:none;border-top:1px solid var(--border)}
323
- /* Conversion toolbar */
324
313
  .codex-editor .ce-conversion-toolbar{background:var(--surface);border:1px solid var(--border);box-shadow:0 8px 24px rgba(0,0,0,.4)}
325
314
  .codex-editor .ce-conversion-tool{color:var(--text2)}
326
315
  .codex-editor .ce-conversion-tool:hover{background:var(--surface2);color:var(--text)}
327
316
  .codex-editor .ce-conversion-tool--active{color:var(--accent)}
328
317
  .codex-editor .ce-conversion-tool__icon{background:var(--surface2);color:var(--text2)}
329
- /* Settings panel */
330
318
  .codex-editor .ce-settings{background:var(--surface);border:1px solid var(--border);box-shadow:0 8px 24px rgba(0,0,0,.4)}
331
319
  .codex-editor .cdx-settings-button{color:var(--text2)}
332
320
  .codex-editor .cdx-settings-button:hover{background:var(--surface2);color:var(--text)}
333
321
  .codex-editor .cdx-settings-button--active{color:var(--accent)}
334
- /* Popover (slash commands menu) */
335
322
  .ce-popover{background:var(--surface)!important;border:1px solid var(--border)!important;box-shadow:0 8px 24px rgba(0,0,0,.4)!important;color:var(--text)}
336
323
  .ce-popover__container{background:var(--surface)!important;border:1px solid var(--border)!important;box-shadow:0 8px 24px rgba(0,0,0,.4)!important}
337
324
  .ce-popover-item{color:var(--text2)!important}
@@ -359,12 +346,10 @@ h4.ce-header{font-size:15px;line-height:1.4}
359
346
  .ce-code__textarea{background:var(--surface)!important;color:var(--text)!important;border:none!important;border-radius:var(--radius-sm)!important;font-family:var(--mono)!important;font-size:13px!important;padding:16px!important;line-height:1.5!important}
360
347
  .cdx-list{font-size:15px;line-height:1.65}
361
348
  .cdx-list__item{color:var(--text)}
362
- /* Checklist in list v2 */
363
349
  .cdx-checklist{font-size:15px}
364
350
  .cdx-checklist__item-checkbox{background:var(--surface2);border:2px solid var(--border2);border-radius:3px}
365
351
  .cdx-checklist__item--checked .cdx-checklist__item-checkbox{background:var(--accent);border-color:var(--accent)}
366
352
  .cdx-checklist__item--checked .cdx-checklist__item-text{color:var(--text2);text-decoration:line-through}
367
- /* List v2 checklist style */
368
353
  .cdx-list--checklist .cdx-list__item-checkbox{background:var(--surface2);border-color:var(--border2)}
369
354
  .cdx-list--checklist .cdx-list__item--checked .cdx-list__item-checkbox{background:var(--accent);border-color:var(--accent)}
370
355
  .cdx-list--checklist .cdx-list__item--checked .cdx-list__item-content{color:var(--text2);text-decoration:line-through}
@@ -373,8 +358,6 @@ h4.ce-header{font-size:15px;line-height:1.4}
373
358
  .cdx-quote__caption{display:none!important}
374
359
  .ce-delimiter{line-height:1;padding:12px 0;color:var(--text3);letter-spacing:8px}
375
360
  .cdx-marker{background:rgba(91,110,245,.2);padding:2px 0}
376
-
377
- /* Fallback textarea (when CDN not available) */
378
361
  .editor-fallback-textarea{width:100%;min-height:200px;resize:vertical;background:transparent;border:none;color:var(--text);padding:0;font-family:var(--font);font-size:15px;line-height:1.65;outline:none}
379
362
 
380
363
  /* ── Markdown Rendered (readonly) ────────────────────────── */
@@ -450,6 +433,35 @@ h4.ce-header{font-size:15px;line-height:1.4}
450
433
  .history-item-path{font-size:11px;color:var(--text3);font-family:var(--mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
451
434
  .history-item-time{font-size:11px;color:var(--text3);font-family:var(--mono);flex-shrink:0;white-space:nowrap}
452
435
  .history-current-badge{display:inline-block;padding:1px 8px;border-radius:10px;font-size:10px;font-weight:600;background:var(--accent);color:#fff}
436
+ .history-open-row{display:flex;gap:8px;padding:12px 20px;border-top:1px solid var(--border)}
437
+ .history-path-input{flex:1;padding:8px 12px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:13px;font-family:var(--font);outline:none;transition:border-color .2s}
438
+ .history-path-input:focus{border-color:var(--accent)}
439
+ .history-path-input::placeholder{color:var(--text3)}
440
+
441
+ /* ── Add Source Modal ───────────────────────────────────── */
442
+ .rail-add{background:transparent;color:var(--text3);border:2px dashed var(--border2);font-size:20px;font-weight:400}
443
+ .rail-add:hover{color:var(--accent);border-color:var(--accent);background:var(--accent-dim);border-style:solid}
444
+ .addsource-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:199;opacity:0;pointer-events:none;transition:opacity .2s}
445
+ .addsource-overlay.open{opacity:1;pointer-events:auto}
446
+ .addsource-modal{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%) scale(.95);width:520px;max-width:92vw;max-height:85vh;background:var(--bg);border:1px solid var(--border);border-radius:12px;z-index:200;display:flex;flex-direction:column;opacity:0;pointer-events:none;transition:all .2s ease;box-shadow:0 16px 48px rgba(0,0,0,.5)}
447
+ .addsource-modal.open{opacity:1;pointer-events:auto;transform:translate(-50%,-50%) scale(1)}
448
+ .addsource-header{padding:16px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
449
+ .addsource-header h2{font-size:16px;font-weight:700}
450
+ .addsource-body{flex:1;overflow-y:auto;padding:20px}
451
+ .addsource-type-toggle{display:flex;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:3px;margin-bottom:20px}
452
+ .addsource-type-btn{flex:1;padding:8px;border:none;background:transparent;color:var(--text2);font-size:13px;font-weight:600;cursor:pointer;border-radius:6px;font-family:var(--font);transition:all .15s}
453
+ .addsource-type-btn.active{background:var(--accent);color:#fff}
454
+ .addsource-field{margin-bottom:16px}
455
+ .addsource-field label{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--text3);font-weight:600;margin-bottom:6px}
456
+ .addsource-field input{width:100%;padding:8px 12px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:13px;font-family:var(--font);outline:none;transition:border-color .2s}
457
+ .addsource-field input:focus{border-color:var(--accent)}
458
+ .addsource-colors{display:flex;gap:8px;flex-wrap:wrap}
459
+ .addsource-color{width:28px;height:28px;border-radius:50%;cursor:pointer;border:3px solid transparent;transition:all .15s;flex-shrink:0}
460
+ .addsource-color:hover{transform:scale(1.15)}
461
+ .addsource-color.active{border-color:var(--text);transform:scale(1.15)}
462
+ .addsource-footer{padding:14px 20px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:8px;flex-shrink:0}
463
+ .addsource-error{color:var(--danger);font-size:12px;margin-bottom:12px;display:none}
464
+ .addsource-error.visible{display:block}
453
465
 
454
466
  /* ── Responsive ──────────────────────────────────────────── */
455
467
  @media(max-width:1024px){
@@ -463,19 +475,21 @@ h4.ce-header{font-size:15px;line-height:1.4}
463
475
  .header{padding:12px 16px}
464
476
  .content{padding:16px}
465
477
  .detail-panel{width:100%;max-width:100%}
466
- #view-notes.active{margin:-16px}
467
478
  .notes-sidebar{width:200px}
468
479
  .notes-header-bar{padding:24px 24px 0}
469
480
  .notes-editorjs-wrap{padding:12px 16px 24px}
481
+ .cards-grid{grid-template-columns:1fr}
470
482
  }
471
483
  @media(max-width:768px){
472
484
  #h-stats{flex-wrap:wrap;gap:12px}
473
485
  .header{gap:12px}
474
486
  .metrics-grid{grid-template-columns:1fr}
487
+ .cards-grid{grid-template-columns:1fr}
475
488
  .notes-sidebar{display:none}
476
489
  .notes-header-bar{padding:20px 16px 0}
477
490
  .notes-editorjs-wrap{padding:8px 8px 24px}
478
491
  }
492
+
479
493
  </style>
480
494
  <style id="dynamic-styles"></style>
481
495
  <style id="theme-styles"></style>
@@ -483,7 +497,7 @@ h4.ce-header{font-size:15px;line-height:1.4}
483
497
  </head>
484
498
  <body>
485
499
  <div class="app">
486
- <!-- Source Rail (Discord/Slack style) -->
500
+ <!-- Source Rail -->
487
501
  <nav class="source-rail" id="source-rail"></nav>
488
502
 
489
503
  <!-- Sidebar -->
@@ -492,78 +506,16 @@ h4.ce-header{font-size:15px;line-height:1.4}
492
506
  <span class="sidebar-logo-icon" id="sidebar-logo-icon">&#9632;</span>
493
507
  <span class="sidebar-logo-text" id="sidebar-logo-text">mdboard</span>
494
508
  </div>
495
- <nav id="sidebar-nav">
496
- <a href="#board" data-view="board" class="active">
497
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
498
- <span>Board</span>
499
- </a>
500
- <a href="#table" data-view="table">
501
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
502
- <span>Table</span>
503
- </a>
504
- <a href="#milestones" data-view="milestones">
505
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>
506
- <span>Milestones</span>
507
- </a>
508
- <a href="#metrics" data-view="metrics">
509
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M18 20V10M12 20V4M6 20v-6"/></svg>
510
- <span>Metrics</span>
511
- </a>
512
- <a href="#notes" data-view="notes">
513
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
514
- <span>Notes</span>
515
- </a>
516
- </nav>
517
- <div class="sidebar-settings" id="sidebar-settings">
518
- <a href="#" id="settings-toggle">
519
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
520
- <span>Settings</span>
521
- </a>
522
- </div>
509
+ <nav id="sidebar-nav"></nav>
523
510
  <div class="sidebar-footer">mdboard</div>
524
511
  </aside>
525
512
 
526
513
  <div class="main">
527
514
  <!-- Header -->
528
- <header class="header">
529
- <div class="header-section" id="h-milestone-wrap" style="display:none">
530
- <span class="header-section-label">Milestone</span>
531
- <span class="header-section-value" id="h-milestone-name"></span>
532
- <div class="progress progress-accent"><div class="progress-fill" id="h-milestone-progress" style="width:0%"></div></div>
533
- </div>
534
- <div class="header-section" id="h-sprint-wrap" style="display:none">
535
- <span class="header-section-label">Sprint</span>
536
- <span class="header-section-value" id="h-sprint-name"></span>
537
- <span style="font-size:11px;color:var(--text2)" id="h-sprint-days"></span>
538
- </div>
539
- <div id="h-stats"></div>
540
- </header>
515
+ <header class="header" id="app-header"></header>
541
516
 
542
517
  <!-- Content -->
543
- <div class="content">
544
- <div id="view-board" class="view active">
545
- <div id="sprint-bar" style="display:none"></div>
546
- <div id="board-filters" class="filter-bar"></div>
547
- <div id="board"></div>
548
- </div>
549
- <div id="view-table" class="view">
550
- <div id="table-controls" class="filter-bar"></div>
551
- <div id="table-wrapper"></div>
552
- </div>
553
- <div id="view-milestones" class="view">
554
- <div id="ms-filters" class="filter-bar"></div>
555
- <div id="milestones-container"></div>
556
- </div>
557
- <div id="view-metrics" class="view">
558
- <div id="metrics-container"></div>
559
- </div>
560
- <div id="view-overview" class="view">
561
- <div id="overview-container"></div>
562
- </div>
563
- <div id="view-notes" class="view">
564
- <div id="notes-container"></div>
565
- </div>
566
- </div>
518
+ <div class="content" id="app-content"></div>
567
519
  </div>
568
520
  </div>
569
521
 
@@ -599,6 +551,10 @@ h4.ce-header{font-size:15px;line-height:1.4}
599
551
  <div id="history-overlay" class="history-overlay"></div>
600
552
  <div id="history-modal" class="history-modal"></div>
601
553
 
554
+ <!-- Add Source Modal -->
555
+ <div id="addsource-overlay" class="addsource-overlay"></div>
556
+ <div id="addsource-modal" class="addsource-modal"></div>
557
+
602
558
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/editorjs@2/dist/editorjs.umd.js"></script>
603
559
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/header@latest/dist/header.umd.js"></script>
604
560
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/list@latest/dist/editorjs-list.umd.js"></script>
@@ -881,21 +837,110 @@ var ICON_SHAPES = {
881
837
  'three-quarter-circle': function(c) { return '<svg class="icon" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6" stroke="' + c + '" stroke-width="1.5"/><path d="M8 2A6 6 0 1 1 2 8L8 8Z" fill="' + c + '"/></svg>'; },
882
838
  'check-circle': function(c) { return '<svg class="icon" viewBox="0 0 16 16"><circle cx="8" cy="8" r="7" fill="' + c + '"/><path d="M5 8.5L7 10.5L11 5.5" stroke="#fff" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>'; },
883
839
  'x-circle': function(c) { return '<svg class="icon" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6" stroke="' + c + '" stroke-width="1.5"/><path d="M5.5 5.5L10.5 10.5M10.5 5.5L5.5 10.5" stroke="' + c + '" stroke-width="1.5" stroke-linecap="round"/></svg>'; },
884
- 'slash-circle': function(c) { return '<svg class="icon" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6" stroke="' + c + '" stroke-width="1.5"/><path d="M4.5 11.5L11.5 4.5" stroke="' + c + '" stroke-width="1.5" stroke-linecap="round"/></svg>'; }
840
+ 'slash-circle': function(c) { return '<svg class="icon" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6" stroke="' + c + '" stroke-width="1.5"/><path d="M4.5 11.5L11.5 4.5" stroke="' + c + '" stroke-width="1.5" stroke-linecap="round"/></svg>'; },
841
+ 'pause-circle': function(c) { return '<svg class="icon" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6" stroke="' + c + '" stroke-width="1.5"/><path d="M6.5 5.5v5M9.5 5.5v5" stroke="' + c + '" stroke-width="1.5" stroke-linecap="round"/></svg>'; }
842
+ };
843
+
844
+ /* Sidebar tab icons */
845
+ var TAB_ICONS = {
846
+ 'check-square': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="3"/><path d="M9 12l2 2 4-4"/></svg>',
847
+ 'target': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1"/></svg>',
848
+ 'refresh-cw': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></svg>',
849
+ 'lightbulb': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18h6M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A7 7 0 1 0 7.5 11.5c.76.76 1.23 1.52 1.41 2.5"/></svg>',
850
+ 'file-text': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>',
851
+ 'bar-chart': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M18 20V10M12 20V4M6 20v-6"/></svg>',
852
+ 'board': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>',
853
+ 'table': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>',
854
+ 'overview': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="9"/><path d="M12 3v18M3 12h18"/></svg>'
855
+ };
856
+
857
+ /* ══════════════════════════════════════════════════════════════
858
+ DATA STORE — fully dynamic, config-driven
859
+ ══════════════════════════════════════════════════════════════ */
860
+ var D = {
861
+ config: null,
862
+ project: null,
863
+ entities: {},
864
+ metrics: null,
865
+ health: null,
866
+ loaded: false,
867
+ sources: [],
868
+ activeSource: null,
869
+ theme: null,
870
+ activeTab: null,
871
+ activeLayouts: {},
872
+ tabFilters: {},
873
+ tabSort: {},
885
874
  };
886
875
 
887
- /* Config-driven maps populated by initFromConfig() */
888
- var STATUS_ICON_MAP = {};
889
- var STATUS_COLOR_MAP = {};
890
- var ENTITY_NAMES = {};
876
+ /* ── Helpers ─────────────────────────────────────────────── */
877
+ function escHtml(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
878
+
879
+ function hashColor(name) {
880
+ var colors = ['#5B6EF5','#8B5CF6','#EC4899','#F59E0B','#10B981','#06B6D4','#F97316','#6366F1'];
881
+ var hash = 0;
882
+ for (var i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
883
+ return colors[Math.abs(hash) % colors.length];
884
+ }
885
+
886
+ function badgeClass(prefix, value) {
887
+ if (!value) return prefix + '-backlog';
888
+ return prefix + '-' + value.toLowerCase().replace(/\s+/g, '-');
889
+ }
890
+
891
+ function fmtDate(d) { return d ? String(d).substring(0, 10) : ''; }
892
+
893
+ /* ── Toast ───────────────────────────────────────────────── */
894
+ function showToast(msg, type) {
895
+ var el = document.createElement('div');
896
+ el.className = 'toast toast-' + (type || 'success');
897
+ el.textContent = msg;
898
+ document.body.appendChild(el);
899
+ setTimeout(function() { el.remove(); }, 3000);
900
+ }
901
+
902
+ /* ── Status & Priority Icons ──────────────────────────────── */
903
+ function statusIcon(entityType, statusKey) {
904
+ if (!D.config || !D.config.entities) return ICON_SHAPES['dashed-circle']('#5A5A63');
905
+ var entity = D.config.entities[entityType];
906
+ if (!entity || !entity.fields) return ICON_SHAPES['dashed-circle']('#5A5A63');
907
+
908
+ // Try status field first, then check all enum fields
909
+ var fields = entity.fields;
910
+ for (var fname in fields) {
911
+ var f = fields[fname];
912
+ if (f.type === 'enum' && f.values) {
913
+ for (var i = 0; i < f.values.length; i++) {
914
+ if (f.values[i].key === statusKey) {
915
+ var v = f.values[i];
916
+ var fn = ICON_SHAPES[v.icon || 'dashed-circle'];
917
+ return fn ? fn(v.color || '#5A5A63') : ICON_SHAPES['dashed-circle'](v.color || '#5A5A63');
918
+ }
919
+ }
920
+ }
921
+ }
922
+ return ICON_SHAPES['dashed-circle']('#5A5A63');
923
+ }
891
924
 
892
- function statusIcon(status) {
893
- var entry = STATUS_ICON_MAP[status];
894
- if (entry) {
895
- var fn = ICON_SHAPES[entry.icon];
896
- if (fn) return fn(entry.color);
925
+ function statusIconByKey(statusKey) {
926
+ if (!D.config || !D.config.entities) return ICON_SHAPES['dashed-circle']('#5A5A63');
927
+ for (var type in D.config.entities) {
928
+ var entity = D.config.entities[type];
929
+ if (entity.fields) {
930
+ for (var fname in entity.fields) {
931
+ var f = entity.fields[fname];
932
+ if (f.type === 'enum' && f.values) {
933
+ for (var i = 0; i < f.values.length; i++) {
934
+ if (f.values[i].key === statusKey) {
935
+ var v = f.values[i];
936
+ var fn = ICON_SHAPES[v.icon || 'dashed-circle'];
937
+ return fn ? fn(v.color || '#5A5A63') : ICON_SHAPES['dashed-circle'](v.color || '#5A5A63');
938
+ }
939
+ }
940
+ }
941
+ }
942
+ }
897
943
  }
898
- // Fallback: dashed circle
899
944
  return ICON_SHAPES['dashed-circle']('#5A5A63');
900
945
  }
901
946
 
@@ -904,13 +949,6 @@ function priorityIcon(priority) {
904
949
  if (D.config && D.config.priorities) {
905
950
  var entry = D.config.priorities.find(function(p) { return p.key === priority; });
906
951
  if (entry) { c = entry.color; n = entry.bars; }
907
- } else {
908
- switch (priority) {
909
- case 'urgent': c = '#F97316'; n = 4; break;
910
- case 'high': c = '#F97316'; n = 3; break;
911
- case 'medium': c = '#D4A72C'; n = 2; break;
912
- case 'low': c = '#5B6EF5'; n = 1; break;
913
- }
914
952
  }
915
953
  var bars = '';
916
954
  for (var i = 0; i < 4; i++) {
@@ -922,74 +960,280 @@ function priorityIcon(priority) {
922
960
  return '<svg class="icon" viewBox="0 0 16 16">' + bars + '</svg>';
923
961
  }
924
962
 
925
- function milestoneIcon(status) {
926
- var msStatuses = D.config && D.config.statuses ? D.config.statuses.milestone : null;
927
- var entry = msStatuses ? msStatuses.find(function(s) { return s.key === status; }) : null;
928
- var c = entry ? entry.color : '#8B8B93';
963
+ /* ── Config-driven entity helpers ─────────────────────────── */
964
+ function getEntityDef(type) {
965
+ return D.config && D.config.entities ? D.config.entities[type] || null : null;
966
+ }
929
967
 
930
- switch (status) {
931
- case 'active':
932
- return '<svg class="icon" viewBox="0 0 16 16"><path d="M8 1L15 8L8 15L1 8Z" fill="none" stroke="' + c + '" stroke-width="1.5"/><path d="M8 5L11 8L8 11L5 8Z" fill="' + c + '"/></svg>';
933
- case 'completed':
934
- return '<svg class="icon" viewBox="0 0 16 16"><path d="M8 1L15 8L8 15L1 8Z" fill="' + c + '"/><path d="M5.5 8L7 9.5L10.5 6" stroke="#fff" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>';
935
- default:
936
- return '<svg class="icon" viewBox="0 0 16 16"><path d="M8 1L15 8L8 15L1 8Z" fill="none" stroke="' + c + '" stroke-width="1.5"/></svg>';
968
+ function getEntityFields(type) {
969
+ var def = getEntityDef(type);
970
+ return def ? def.fields || {} : {};
971
+ }
972
+
973
+ function getEntityStatuses(type) {
974
+ var fields = getEntityFields(type);
975
+ return fields.status && fields.status.type === 'enum' ? fields.status.values : null;
976
+ }
977
+
978
+ function getEntities(type) {
979
+ return D.entities[type] || [];
980
+ }
981
+
982
+ function getEntityPlural(type) {
983
+ var def = getEntityDef(type);
984
+ return def ? def.plural.toLowerCase() : type + 's';
985
+ }
986
+
987
+ function getEntitySingular(type) {
988
+ var def = getEntityDef(type);
989
+ return def ? def.singular : type;
990
+ }
991
+
992
+ function getTabConfig(tabId) {
993
+ if (!D.config || !D.config.ui || !D.config.ui.tabs) return null;
994
+ return D.config.ui.tabs.find(function(t) { return t.id === tabId; }) || null;
995
+ }
996
+
997
+ function getActiveLayout(tabId) {
998
+ if (D.activeLayouts[tabId]) return D.activeLayouts[tabId];
999
+ var tab = getTabConfig(tabId);
1000
+ return tab ? tab.defaultLayout : 'board';
1001
+ }
1002
+
1003
+ /* ── Filtering helpers ───────────────────────────────────── */
1004
+ function getAutoFilters(entityType) {
1005
+ if (!D.config || !D.config.entities) return [];
1006
+ var def = getEntityDef(entityType);
1007
+ if (!def || !def.fields) return [];
1008
+ var filters = [];
1009
+ for (var name in def.fields) {
1010
+ var f = def.fields[name];
1011
+ if (f.type === 'enum') filters.push(name);
937
1012
  }
1013
+ // Add ancestor types as filters
1014
+ var ancestors = findAncestors(entityType);
1015
+ for (var i = 0; i < ancestors.length; i++) {
1016
+ filters.push(ancestors[i]);
1017
+ }
1018
+ return filters;
938
1019
  }
939
1020
 
940
- /* ══════════════════════════════════════════════════════════════
941
- DATA STORE
942
- ══════════════════════════════════════════════════════════════ */
943
- var D = {
944
- config: null, project: null, milestones: [], epics: [], tasks: [],
945
- sprints: [], allSprints: [], metrics: null, health: null, loaded: false,
946
- sources: [], activeSource: null, overviewLinks: null, notes: [],
947
- aiSuggestions: null, theme: null
948
- };
1021
+ function getFilterValues(entityType, field) {
1022
+ var items = getEntities(entityType);
1023
+ var def = getEntityDef(entityType);
1024
+ var fieldDef = def && def.fields ? def.fields[field] : null;
949
1025
 
950
- /* ── Helpers ─────────────────────────────────────────────── */
951
- function escHtml(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
1026
+ // Enum field return all possible values
1027
+ if (fieldDef && fieldDef.type === 'enum' && fieldDef.values) {
1028
+ return fieldDef.values.map(function(v) { return v.key; });
1029
+ }
952
1030
 
953
- function epicColor(name) {
954
- var colors = ['#5B6EF5','#8B5CF6','#EC4899','#F59E0B','#10B981','#06B6D4','#F97316','#6366F1'];
955
- var hash = 0;
956
- for (var i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
957
- return colors[Math.abs(hash) % colors.length];
1031
+ // Ancestor ref — collect unique values from items
1032
+ var vals = [];
1033
+ items.forEach(function(item) {
1034
+ var v = item['_' + field] || item[field];
1035
+ if (v && vals.indexOf(v) === -1) vals.push(v);
1036
+ });
1037
+ return vals;
958
1038
  }
959
1039
 
960
- function badgeClass(prefix, value) {
961
- if (!value) return prefix + '-backlog';
962
- return prefix + '-' + value.toLowerCase().replace(/\s+/g, '-');
1040
+ function getFieldLabel(entityType, fieldName, value) {
1041
+ var def = getEntityDef(entityType);
1042
+ if (!def || !def.fields) return value || '';
1043
+ var field = def.fields[fieldName];
1044
+ if (field && field.type === 'enum' && field.values) {
1045
+ var match = field.values.find(function(v) { return v.key === value; });
1046
+ if (match) return match.label;
1047
+ }
1048
+ // Ancestor label
1049
+ if (!field) {
1050
+ var ancDef = getEntityDef(fieldName);
1051
+ if (ancDef) return value || '';
1052
+ }
1053
+ return value || '';
963
1054
  }
964
1055
 
965
- function fmtDate(d) { return d ? String(d).substring(0, 10) : ''; }
1056
+ function getFieldColor(entityType, fieldName, value) {
1057
+ var def = getEntityDef(entityType);
1058
+ if (!def || !def.fields) return null;
1059
+ var field = def.fields[fieldName];
1060
+ if (field && field.type === 'enum' && field.values) {
1061
+ var match = field.values.find(function(v) { return v.key === value; });
1062
+ if (match) return match.color || null;
1063
+ }
1064
+ return null;
1065
+ }
1066
+
1067
+ function getFilteredItems(tabConfig) {
1068
+ if (!tabConfig || !tabConfig.entity) return [];
1069
+ var type = tabConfig.entity;
1070
+ var items = getEntities(type).slice();
1071
+ var tabId = tabConfig.id;
1072
+ var filters = D.tabFilters[tabId] || {};
1073
+
1074
+ // Apply search filter
1075
+ if (filters._search) {
1076
+ var q = filters._search.toLowerCase();
1077
+ items = items.filter(function(x) {
1078
+ return (x.id || '').toLowerCase().indexOf(q) !== -1 ||
1079
+ (x.title || '').toLowerCase().indexOf(q) !== -1;
1080
+ });
1081
+ }
1082
+
1083
+ // Apply field filters
1084
+ for (var field in filters) {
1085
+ if (field === '_search') continue;
1086
+ var val = filters[field];
1087
+ if (!val) continue;
1088
+ items = items.filter(function(x) {
1089
+ return x[field] === val || x['_' + field] === val;
1090
+ });
1091
+ }
966
1092
 
967
- function daysRemaining(endDate) {
968
- if (!endDate) return null;
969
- var end = new Date(endDate); var now = new Date();
970
- return Math.ceil((end - now) / 86400000);
1093
+ // Apply sort
1094
+ var sort = D.tabSort[tabId];
1095
+ if (sort && sort.col) {
1096
+ var col = sort.col, dir = sort.asc ? 1 : -1;
1097
+ items.sort(function(a, b) {
1098
+ var va = a[col] == null ? '' : a[col], vb = b[col] == null ? '' : b[col];
1099
+ if (typeof va === 'number' && typeof vb === 'number') return (va - vb) * dir;
1100
+ return String(va).localeCompare(String(vb)) * dir;
1101
+ });
1102
+ }
1103
+
1104
+ return items;
971
1105
  }
972
1106
 
973
- var STATUSES = ['backlog','todo','in-progress','in-review','done','blocked','cancelled'];
974
- var PRIORITIES = ['urgent','high','medium','low'];
975
- var STATUS_LABELS = {backlog:'Backlog',todo:'Todo','in-progress':'In Progress','in-review':'In Review',done:'Done',blocked:'Blocked',cancelled:'Cancelled'};
976
- var PRIORITY_LABELS = {urgent:'Urgent',high:'High',medium:'Medium',low:'Low'};
977
- var BOARD_COLS = ['backlog','todo','in-progress','in-review','done'];
978
- var COMPLETED_STATUS = 'done';
1107
+ /* ── Hierarchy helpers ───────────────────────────────────── */
1108
+ function findAncestors(type) {
1109
+ if (!D.config || !D.config.hierarchy) return [];
1110
+ var ancestors = [];
1111
+ function walk(node, target, path) {
1112
+ for (var t in node) {
1113
+ if (t === target) { return path.slice(); }
1114
+ if (node[t].children) {
1115
+ var result = walk(node[t].children, target, path.concat(t));
1116
+ if (result) return result;
1117
+ }
1118
+ }
1119
+ return null;
1120
+ }
1121
+ return walk(D.config.hierarchy, type, []) || [];
1122
+ }
979
1123
 
980
- /* ── Toast ───────────────────────────────────────────────── */
981
- function showToast(msg, type) {
982
- var el = document.createElement('div');
983
- el.className = 'toast toast-' + (type || 'success');
984
- el.textContent = msg;
985
- document.body.appendChild(el);
986
- setTimeout(function() { el.remove(); }, 3000);
1124
+ function findChildren(type) {
1125
+ if (!D.config || !D.config.hierarchy) return [];
1126
+ function walk(node) {
1127
+ for (var t in node) {
1128
+ if (t === type && node[t].children) {
1129
+ return Object.keys(node[t].children);
1130
+ }
1131
+ if (node[t].children) {
1132
+ var result = walk(node[t].children);
1133
+ if (result) return result;
1134
+ }
1135
+ }
1136
+ return [];
1137
+ }
1138
+ return walk(D.config.hierarchy);
1139
+ }
1140
+
1141
+ function isCompletedStatus(status) {
1142
+ if (!D.config || !D.config.completedStatuses) return status === 'done';
1143
+ return D.config.completedStatuses.indexOf(status) !== -1;
1144
+ }
1145
+
1146
+ function isStandaloneType(type) {
1147
+ return D.config && D.config.standalone && !!D.config.standalone[type];
1148
+ }
1149
+
1150
+ /* ── Rendering helpers ───────────────────────────────────── */
1151
+ function renderFieldPill(item, entityType, field) {
1152
+ var value = item[field];
1153
+ if (value == null || value === '') return '';
1154
+ var def = getEntityDef(entityType);
1155
+ var fieldDef = def && def.fields ? def.fields[field] : null;
1156
+
1157
+ if (fieldDef && fieldDef.type === 'enum' && fieldDef.values) {
1158
+ var match = fieldDef.values.find(function(v) { return v.key === value; });
1159
+ if (match) {
1160
+ var color = match.color || 'var(--text2)';
1161
+ return '<span class="pill" style="background:' + color + '20;color:' + color + '">' +
1162
+ (match.icon ? statusIcon(entityType, value) + ' ' : '') +
1163
+ escHtml(match.label) + '</span>';
1164
+ }
1165
+ }
1166
+
1167
+ if (fieldDef && fieldDef.type === 'longtext') {
1168
+ var ltText = String(value);
1169
+ var ltTrunc = ltText.length > 50 ? ltText.substring(0, 50) + '...' : ltText;
1170
+ return '<span class="pill" style="background:var(--surface2);color:var(--text2)">' + escHtml(ltTrunc) + '</span>';
1171
+ }
1172
+
1173
+ if (fieldDef && fieldDef.type === 'number' && value != null) {
1174
+ return '<span class="pill pill-points">' + value + (fieldDef.label === 'Points' ? ' pts' : '') + '</span>';
1175
+ }
1176
+
1177
+ if (fieldDef && fieldDef.type === 'list') {
1178
+ var arr = Array.isArray(value) ? value : String(value).split(',').map(function(s) { return s.trim(); });
1179
+ return arr.filter(Boolean).map(function(v) {
1180
+ return '<span class="pill" style="background:var(--surface2);color:var(--text2)">' + escHtml(v) + '</span>';
1181
+ }).join('');
1182
+ }
1183
+
1184
+ return '<span class="pill" style="background:var(--surface2);color:var(--text2)">' + escHtml(String(value)) + '</span>';
1185
+ }
1186
+
1187
+ function renderFieldCell(item, entityType, col) {
1188
+ if (col === 'id') return '<span class="td-id">' + escHtml(item.id || '') + '</span>';
1189
+ if (col === 'title') return '<span class="td-title">' + escHtml(item.title || '') + '</span>';
1190
+
1191
+ var value = item[col] || item['_' + col];
1192
+ if (value == null || value === '') return '';
1193
+
1194
+ var def = getEntityDef(entityType);
1195
+ var fieldDef = def && def.fields ? def.fields[col] : null;
1196
+
1197
+ if (fieldDef && fieldDef.type === 'enum') {
1198
+ return '<span class="badge ' + badgeClass('badge', value) + '">' +
1199
+ statusIcon(entityType, value) + ' ' + escHtml(getFieldLabel(entityType, col, value)) + '</span>';
1200
+ }
1201
+
1202
+ if (fieldDef && fieldDef.type === 'longtext') {
1203
+ var ltText = String(value);
1204
+ var ltTrunc = ltText.length > 50 ? ltText.substring(0, 50) + '...' : ltText;
1205
+ return '<span style="color:var(--text2)">' + escHtml(ltTrunc) + '</span>';
1206
+ }
1207
+
1208
+ if (fieldDef && fieldDef.type === 'list') {
1209
+ var arr = Array.isArray(value) ? value : [value];
1210
+ return escHtml(arr.join(', '));
1211
+ }
1212
+
1213
+ // Ancestor ref column
1214
+ if (!fieldDef && value) {
1215
+ return escHtml(String(value));
1216
+ }
1217
+
1218
+ return escHtml(String(value));
1219
+ }
1220
+
1221
+ function emptyState(entityType) {
1222
+ var singular = getEntitySingular(entityType);
1223
+ return '<div class="empty"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">' +
1224
+ '<rect x="3" y="3" width="18" height="18" rx="3"/><path d="M9 12h6M12 9v6"/></svg>' +
1225
+ '<p>No ' + escHtml(singular.toLowerCase()) + 's yet.</p></div>';
987
1226
  }
988
1227
 
989
1228
  /* ── Data Fetching ───────────────────────────────────────── */
1229
+ function apiBase() {
1230
+ if (D.activeSource) return '/api/sources/' + encodeURIComponent(D.activeSource);
1231
+ return '/api';
1232
+ }
1233
+
990
1234
  async function fetchJson(url) {
991
1235
  try { var r = await fetch(url); return r.ok ? await r.json() : null; }
992
- catch { return null; }
1236
+ catch(e) { return null; }
993
1237
  }
994
1238
 
995
1239
  async function patchItem(type, id, updates) {
@@ -1014,12 +1258,6 @@ async function patchItem(type, id, updates) {
1014
1258
  }
1015
1259
  }
1016
1260
 
1017
- /* ── CRUD API helpers ─────────────────────────────────────── */
1018
- function apiBase() {
1019
- if (D.activeSource) return '/api/sources/' + encodeURIComponent(D.activeSource);
1020
- return '/api';
1021
- }
1022
-
1023
1261
  async function createItem(collection, data) {
1024
1262
  try {
1025
1263
  var url = apiBase() + '/' + collection;
@@ -1062,28 +1300,41 @@ async function deleteItem(collection, id) {
1062
1300
 
1063
1301
  /* ── Data Loading ────────────────────────────────────────── */
1064
1302
  async function loadAll() {
1303
+ D.config = await fetchJson('/api/config');
1304
+ if (!D.config) return;
1065
1305
  var base = apiBase();
1066
- var results = await Promise.all([
1067
- fetchJson(base + '/project'), fetchJson(base + '/milestones'), fetchJson(base + '/epics'),
1068
- fetchJson(base + '/tasks'), fetchJson(base + '/sprint'), fetchJson(base + '/metrics'),
1069
- fetchJson(base + '/health'), fetchJson(base + '/sprints'), fetchJson('/api/config'),
1070
- fetchJson('/api/sources'), fetchJson(base + '/notes')
1071
- ]);
1072
- D.project = results[0]; D.milestones = results[1] || []; D.epics = results[2] || [];
1073
- D.tasks = results[3] || [];
1074
- if (results[4]) D.sprints = [results[4]]; else D.sprints = [];
1075
- D.metrics = results[5]; D.health = results[6];
1076
- D.allSprints = results[7] || [];
1077
- D.config = results[8];
1078
- D.sources = results[9] || [];
1079
- D.notes = results[10] || [];
1306
+ var types = D.config.entities ? Object.keys(D.config.entities) : [];
1307
+
1308
+ var fetches = types.map(function(t) {
1309
+ var def = D.config.entities[t];
1310
+ return fetchJson(base + '/' + def.plural.toLowerCase());
1311
+ });
1312
+ fetches.push(fetchJson('/api/project'));
1313
+ fetches.push(fetchJson('/api/metrics'));
1314
+ fetches.push(fetchJson('/api/health'));
1315
+ fetches.push(fetchJson('/api/sources'));
1316
+
1317
+ var results = await Promise.all(fetches);
1318
+
1319
+ types.forEach(function(t, i) {
1320
+ D.entities[t] = results[i] || [];
1321
+ });
1322
+ var offset = types.length;
1323
+ D.project = results[offset] || {};
1324
+ D.metrics = results[offset + 1];
1325
+ D.health = results[offset + 2];
1326
+ D.sources = results[offset + 3] || [];
1080
1327
  D.loaded = true;
1328
+
1329
+ // Apply the project's theme + reload custom CSS
1330
+ applyTheme(getActiveTheme());
1331
+ reloadCustomCss();
1081
1332
  }
1082
1333
 
1083
1334
  function refreshData() {
1084
1335
  loadAll().then(function() {
1085
- initFromConfig();
1086
- renderAll();
1336
+ generateDynamicStyles();
1337
+ renderCurrentView();
1087
1338
  if (hasWorkspace()) buildSourceRail();
1088
1339
  });
1089
1340
  }
@@ -1092,72 +1343,30 @@ function hasWorkspace() {
1092
1343
  return D.config && D.config.workspace && D.config.workspace.sources && D.config.workspace.sources.length > 0;
1093
1344
  }
1094
1345
 
1095
- /* ── Config-driven initialization ────────────────────────── */
1096
- function initFromConfig() {
1097
- if (!D.config) return;
1098
-
1099
- var cfg = D.config;
1100
-
1101
- // Derive entity names
1102
- ENTITY_NAMES = cfg.entities || {};
1103
-
1104
- // Derive statuses, labels, board columns
1105
- if (cfg.statuses && cfg.statuses.task) {
1106
- STATUSES = cfg.statuses.task.map(function(s) { return s.key; });
1107
- STATUS_LABELS = {};
1108
- cfg.statuses.task.forEach(function(s) { STATUS_LABELS[s.key] = s.label; });
1109
- }
1110
-
1111
- // Build icon/color maps for all status types
1112
- STATUS_ICON_MAP = {};
1113
- STATUS_COLOR_MAP = {};
1114
- if (cfg.statuses) {
1115
- Object.keys(cfg.statuses).forEach(function(entityType) {
1116
- cfg.statuses[entityType].forEach(function(s) {
1117
- if (!STATUS_ICON_MAP[s.key]) {
1118
- STATUS_ICON_MAP[s.key] = { icon: s.icon || 'dashed-circle', color: s.color || '#5A5A63' };
1119
- STATUS_COLOR_MAP[s.key] = s.color || '#5A5A63';
1120
- }
1121
- });
1122
- });
1123
- }
1124
-
1125
- // Derive priorities
1126
- if (cfg.priorities) {
1127
- PRIORITIES = cfg.priorities.map(function(p) { return p.key; });
1128
- PRIORITY_LABELS = {};
1129
- cfg.priorities.forEach(function(p) { PRIORITY_LABELS[p.key] = p.label; });
1130
- }
1131
-
1132
- // Board columns
1133
- if (cfg.boardColumns) BOARD_COLS = cfg.boardColumns;
1134
-
1135
- // Completed status
1136
- if (cfg.completedStatus) COMPLETED_STATUS = cfg.completedStatus;
1137
-
1138
- generateDynamicStyles();
1346
+ function isSourceWritable() {
1347
+ if (!hasWorkspace() || !D.activeSource) return true;
1348
+ var src = D.config.workspace.sources.find(function(s) { return s.name === D.activeSource; });
1349
+ return src ? !src.readonly : true;
1139
1350
  }
1140
1351
 
1352
+ /* ── Config-driven initialization ────────────────────────── */
1141
1353
  function generateDynamicStyles() {
1354
+ if (!D.config || !D.config.entities) return;
1142
1355
  var css = '';
1143
- if (D.config && D.config.statuses && D.config.statuses.task) {
1144
- D.config.statuses.task.forEach(function(s) {
1145
- var key = s.key;
1146
- var color = s.color;
1147
- css += '.badge-' + key + '{background:' + color + '20;color:' + color + '}\n';
1148
- css += '.card[data-status="' + key + '"]{border-left-color:' + color + '}\n';
1149
- });
1150
- }
1151
- // Milestone/epic/sprint statuses
1152
- ['milestone', 'epic', 'sprint'].forEach(function(entityType) {
1153
- if (D.config && D.config.statuses && D.config.statuses[entityType]) {
1154
- D.config.statuses[entityType].forEach(function(s) {
1155
- css += '.badge-' + s.key + '{background:' + s.color + '20;color:' + s.color + '}\n';
1156
- });
1356
+ for (var type in D.config.entities) {
1357
+ var entity = D.config.entities[type];
1358
+ if (!entity.fields) continue;
1359
+ for (var fname in entity.fields) {
1360
+ var field = entity.fields[fname];
1361
+ if (field.type === 'enum' && field.values) {
1362
+ field.values.forEach(function(v) {
1363
+ css += '.badge-' + v.key + '{background:' + v.color + '20;color:' + v.color + '}\n';
1364
+ css += '.card[data-status="' + v.key + '"]{border-left-color:' + v.color + '}\n';
1365
+ });
1366
+ }
1157
1367
  }
1158
- });
1159
- // Priority badges
1160
- if (D.config && D.config.priorities) {
1368
+ }
1369
+ if (D.config.priorities) {
1161
1370
  D.config.priorities.forEach(function(p) {
1162
1371
  css += '.badge-' + p.key + '{background:' + p.color + '20;color:' + p.color + '}\n';
1163
1372
  });
@@ -1166,13 +1375,11 @@ function generateDynamicStyles() {
1166
1375
  if (styleEl) styleEl.textContent = css;
1167
1376
  }
1168
1377
 
1169
- /* ══════════════════════════════════════════════════════════════
1170
- THEME ENGINE
1171
- ══════════════════════════════════════════════════════════════ */
1378
+ /* ── Theme Engine ────────────────────────────────────────── */
1172
1379
  function generateThemeCss(themeId) {
1173
1380
  var theme = THEMES[themeId];
1174
1381
  if (!theme) return '';
1175
- var css = '/* mdboard theme: ' + theme.name + ' */\n:root {\n';
1382
+ var css = ':root {\n';
1176
1383
  for (var key in theme.vars) {
1177
1384
  if (theme.vars.hasOwnProperty(key)) {
1178
1385
  css += ' --' + key + ': ' + theme.vars[key] + ';\n';
@@ -1203,206 +1410,42 @@ function getActiveTheme() {
1203
1410
  return D.config && D.config.theme ? D.config.theme : 'default-dark';
1204
1411
  }
1205
1412
 
1413
+
1206
1414
  /* ══════════════════════════════════════════════════════════════
1207
- WORKSPACESource switching & sidebar
1415
+ EDITOR.JS WRAPPER md↔blocks conversion + Notion-like UX
1208
1416
  ══════════════════════════════════════════════════════════════ */
1209
- function renderSidebarLogo() {
1210
- var p = D.project || {};
1211
- var logoEl = document.getElementById('sidebar-logo');
1212
- var iconEl = document.getElementById('sidebar-logo-icon');
1213
- var textEl = document.getElementById('sidebar-logo-text');
1214
1417
 
1215
- textEl.textContent = p.name || 'Project';
1216
-
1217
- if (D.config && D.config.logo) {
1218
- var logoSrc = D.config.logo.startsWith('http') ? D.config.logo : '/logo';
1219
- iconEl.innerHTML = '<img src="' + escHtml(logoSrc) + '" alt="">';
1220
- } else {
1221
- var initial = (p.name || 'P').charAt(0).toUpperCase();
1222
- iconEl.textContent = initial;
1223
- }
1418
+ /* ── Inline Markdown ↔ HTML ──────────────────────────────── */
1419
+ function inlineMdToHtml(text) {
1420
+ if (!text) return '';
1421
+ text = text.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
1422
+ text = text.replace(/\*(.+?)\*/g, '<i>$1</i>');
1423
+ text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
1424
+ text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
1425
+ return text;
1426
+ }
1224
1427
 
1225
- logoEl.onclick = function() {
1226
- if (hasWorkspace()) {
1227
- switchSource('overview');
1228
- }
1229
- };
1428
+ function inlineHtmlToMd(html) {
1429
+ if (!html) return '';
1430
+ var text = html.replace(/<br\s*\/?>/gi, '\n');
1431
+ text = text.replace(/<a[^>]+href="([^"]*)"[^>]*>([^<]*)<\/a>/gi, '[$2]($1)');
1432
+ text = text.replace(/<b>([^<]*)<\/b>/gi, '**$1**');
1433
+ text = text.replace(/<strong>([^<]*)<\/strong>/gi, '**$1**');
1434
+ text = text.replace(/<i>([^<]*)<\/i>/gi, '*$1*');
1435
+ text = text.replace(/<em>([^<]*)<\/em>/gi, '*$1*');
1436
+ text = text.replace(/<code>([^<]*)<\/code>/gi, '`$1`');
1437
+ text = text.replace(/<mark[^>]*>([^<]*)<\/mark>/gi, '==$1==');
1438
+ text = text.replace(/<[^>]+>/g, '');
1439
+ return text;
1230
1440
  }
1231
1441
 
1232
- function buildSourceRail() {
1233
- var rail = document.getElementById('source-rail');
1234
- if (!rail) return;
1235
- rail.innerHTML = '';
1442
+ /* ── Markdown → Editor.js Blocks ────────────────────────── */
1443
+ function markdownToBlocks(md) {
1444
+ if (!md || !md.trim()) return [{ type: 'paragraph', data: { text: '' } }];
1236
1445
 
1237
- if (!hasWorkspace()) return;
1238
-
1239
- // Home icon (overview)
1240
- var home = document.createElement('div');
1241
- home.className = 'rail-icon' + (D.activeSource === 'overview' ? ' active' : '');
1242
- home.dataset.source = 'overview';
1243
- home.setAttribute('data-tooltip', 'Overview');
1244
- home.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>';
1245
- home.onclick = function() { switchSource('overview'); };
1246
- rail.appendChild(home);
1247
-
1248
- // Divider
1249
- var divider = document.createElement('div');
1250
- divider.className = 'rail-divider';
1251
- rail.appendChild(divider);
1252
-
1253
- // Source icons
1254
- var wsSources = D.config.workspace.sources;
1255
- for (var i = 0; i < wsSources.length; i++) {
1256
- var s = wsSources[i];
1257
- if (s.name === 'overview') continue;
1258
-
1259
- var color = s.color || 'var(--accent)';
1260
- var icon = document.createElement('div');
1261
- icon.className = 'rail-icon' + (D.activeSource === s.name ? ' active' : '');
1262
- icon.dataset.source = s.name;
1263
- icon.setAttribute('data-tooltip', s.label || s.name);
1264
- icon.style.background = color + '20';
1265
- icon.style.color = color;
1266
- icon.innerHTML = escHtml(s.icon || s.name.charAt(0).toUpperCase());
1267
- icon.onclick = (function(name) {
1268
- return function() { switchSource(name); };
1269
- })(s.name);
1270
- rail.appendChild(icon);
1271
- }
1272
-
1273
- // Switch project icon (bottom of rail)
1274
- var switchDivider = document.createElement('div');
1275
- switchDivider.className = 'rail-divider';
1276
- switchDivider.style.marginTop = 'auto';
1277
- rail.appendChild(switchDivider);
1278
-
1279
- var switchIcon = document.createElement('div');
1280
- switchIcon.className = 'rail-icon rail-switch';
1281
- switchIcon.setAttribute('data-tooltip', 'Switch workspace');
1282
- switchIcon.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 3h5v5"/><path d="M21 3l-7 7"/><path d="M8 21H3v-5"/><path d="M3 21l7-7"/></svg>';
1283
- switchIcon.onclick = function() { openHistoryModal(); };
1284
- rail.appendChild(switchIcon);
1285
- }
1286
-
1287
- function switchSource(sourceName) {
1288
- if (sourceName === D.activeSource) return;
1289
-
1290
- // Update active source marker
1291
- D.activeSource = sourceName;
1292
- D.loaded = false;
1293
-
1294
- // Persist selection
1295
- try { localStorage.setItem('mdboard-source', sourceName); } catch(e) {}
1296
-
1297
- // Update source rail active state
1298
- document.querySelectorAll('.rail-icon').forEach(function(el) {
1299
- el.classList.toggle('active', el.dataset.source === sourceName);
1300
- });
1301
-
1302
- // If switching to overview, show overview tabs; otherwise show normal tabs
1303
- var isOverview = sourceName === 'overview';
1304
- var viewTabs = document.querySelectorAll('#sidebar-nav a[data-view]');
1305
- viewTabs.forEach(function(tab) {
1306
- var view = tab.dataset.view;
1307
- if (isOverview) {
1308
- tab.style.display = (view === 'overview' || view === 'metrics') ? '' : 'none';
1309
- } else {
1310
- tab.style.display = (view === 'overview') ? 'none' : '';
1311
- }
1312
- });
1313
-
1314
- // Switch to appropriate view
1315
- if (isOverview) {
1316
- switchView('overview');
1317
- } else {
1318
- var currentView = document.querySelector('.view.active');
1319
- if (currentView && currentView.id === 'view-overview') {
1320
- switchView('board');
1321
- }
1322
- }
1323
-
1324
- // Reload data for this source
1325
- loadAll().then(function() {
1326
- initFromConfig();
1327
- renderAll();
1328
- buildSourceRail();
1329
- });
1330
- }
1331
-
1332
- /* ── SSE — Hot Reload ────────────────────────────────────── */
1333
- function connectSSE() {
1334
- try {
1335
- var src = new EventSource('/api/events');
1336
- src.onmessage = function(e) {
1337
- var data;
1338
- try { data = JSON.parse(e.data); } catch { data = {}; }
1339
-
1340
- if (data.type === 'project-changed') {
1341
- // Full reload on project switch (from another tab or API call)
1342
- D.activeSource = null;
1343
- D.loaded = false;
1344
- loadAll().then(function() {
1345
- initFromConfig();
1346
- if (hasWorkspace()) {
1347
- buildSourceRail();
1348
- D.activeSource = null;
1349
- switchSource('overview');
1350
- } else {
1351
- // Hide source rail if no workspace
1352
- var rail = document.getElementById('source-rail');
1353
- if (rail) rail.innerHTML = '';
1354
- renderAll();
1355
- }
1356
- renderSidebarLogo();
1357
- });
1358
- return;
1359
- }
1360
-
1361
- if (data.cssReload) {
1362
- var link = document.getElementById('custom-theme');
1363
- if (link) link.href = '/mdboard.css?' + Date.now();
1364
- }
1365
- refreshData();
1366
- };
1367
- src.onerror = function() { setTimeout(connectSSE, 5000); src.close(); };
1368
- } catch { /* no SSE support */ }
1369
- }
1370
-
1371
- /* ══════════════════════════════════════════════════════════════
1372
- EDITOR.JS WRAPPER — md↔blocks conversion + Notion-like UX
1373
- ══════════════════════════════════════════════════════════════ */
1374
-
1375
- /* ── Inline Markdown ↔ HTML ──────────────────────────────── */
1376
- function inlineMdToHtml(text) {
1377
- if (!text) return '';
1378
- text = text.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
1379
- text = text.replace(/\*(.+?)\*/g, '<i>$1</i>');
1380
- text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
1381
- text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
1382
- return text;
1383
- }
1384
-
1385
- function inlineHtmlToMd(html) {
1386
- if (!html) return '';
1387
- var text = html.replace(/<br\s*\/?>/gi, '\n');
1388
- text = text.replace(/<a[^>]+href="([^"]*)"[^>]*>([^<]*)<\/a>/gi, '[$2]($1)');
1389
- text = text.replace(/<b>([^<]*)<\/b>/gi, '**$1**');
1390
- text = text.replace(/<strong>([^<]*)<\/strong>/gi, '**$1**');
1391
- text = text.replace(/<i>([^<]*)<\/i>/gi, '*$1*');
1392
- text = text.replace(/<em>([^<]*)<\/em>/gi, '*$1*');
1393
- text = text.replace(/<code>([^<]*)<\/code>/gi, '`$1`');
1394
- text = text.replace(/<mark[^>]*>([^<]*)<\/mark>/gi, '==$1==');
1395
- text = text.replace(/<[^>]+>/g, '');
1396
- return text;
1397
- }
1398
-
1399
- /* ── Markdown → Editor.js Blocks ────────────────────────── */
1400
- function markdownToBlocks(md) {
1401
- if (!md || !md.trim()) return [{ type: 'paragraph', data: { text: '' } }];
1402
-
1403
- var lines = md.split('\n');
1404
- var blocks = [];
1405
- var i = 0;
1446
+ var lines = md.split('\n');
1447
+ var blocks = [];
1448
+ var i = 0;
1406
1449
 
1407
1450
  while (i < lines.length) {
1408
1451
  var line = lines[i];
@@ -1689,118 +1732,111 @@ function simpleMarkdownRender(md) {
1689
1732
 
1690
1733
 
1691
1734
  /* ══════════════════════════════════════════════════════════════
1692
- BOARD VIEW
1735
+ BOARD VIEW — Generic kanban board from tabConfig
1693
1736
  ══════════════════════════════════════════════════════════════ */
1694
- var boardFilters = { search: '', priority: '', epic: '', milestone: '' };
1695
-
1696
- function renderBoardFilters() {
1697
- var c = document.getElementById('board-filters');
1698
- var ep = [], ms = [];
1699
- D.tasks.forEach(function(f) {
1700
- if (f.epic && ep.indexOf(f.epic) === -1) ep.push(f.epic);
1701
- if (f.milestone && ms.indexOf(f.milestone) === -1) ms.push(f.milestone);
1702
- });
1703
1737
 
1704
- var epicPlural = ENTITY_NAMES.epic ? ENTITY_NAMES.epic.plural : 'Epics';
1705
- var msPlural = ENTITY_NAMES.milestone ? ENTITY_NAMES.milestone.plural : 'Milestones';
1738
+ function renderBoard(container, tabConfig) {
1739
+ if (!D.loaded || !tabConfig || !tabConfig.entity) {
1740
+ container.innerHTML = '<div class="loading-container">' +
1741
+ '<div class="skeleton skeleton-card"></div>'.repeat(3) + '</div>';
1742
+ return;
1743
+ }
1706
1744
 
1707
- function opts(arr, key, label) {
1708
- return '<select data-filter="' + key + '"><option value="">All ' + label + '</option>' +
1709
- arr.map(function(v) { return '<option value="' + escHtml(v) + '"' + (boardFilters[key] === v ? ' selected' : '') + '>' + escHtml(v) + '</option>'; }).join('') + '</select>';
1745
+ var entityType = tabConfig.entity;
1746
+ var boardConfig = tabConfig.board || {};
1747
+ var groupBy = boardConfig.groupBy || 'status';
1748
+ var cardFields = boardConfig.cardFields || ['id', 'title'];
1749
+ var items = getFilteredItems(tabConfig);
1750
+
1751
+ // Get columns from the groupBy field's enum values
1752
+ var fields = getEntityFields(entityType);
1753
+ var groupField = fields[groupBy];
1754
+ var columns = [];
1755
+ if (groupField && groupField.type === 'enum' && groupField.values) {
1756
+ columns = groupField.values.map(function(v) { return { key: v.key, label: v.label, color: v.color }; });
1757
+ } else {
1758
+ // Fallback: derive columns from data
1759
+ var seen = {};
1760
+ items.forEach(function(item) {
1761
+ var v = item[groupBy] || 'none';
1762
+ if (!seen[v]) { seen[v] = true; columns.push({ key: v, label: v, color: null }); }
1763
+ });
1710
1764
  }
1711
1765
 
1712
- var createBtn = isSourceWritable() ?
1713
- '<button class="btn btn-sm btn-create" id="board-create-btn">+ New ' + escHtml(ENTITY_NAMES.task ? ENTITY_NAMES.task.singular : 'Task') + '</button>' : '';
1766
+ if (items.length === 0 && getEntities(entityType).length === 0) {
1767
+ container.innerHTML = emptyState(entityType);
1768
+ return;
1769
+ }
1714
1770
 
1715
- c.innerHTML = createBtn +
1716
- '<input type="text" data-filter="search" placeholder="Search cards..." value="' + escHtml(boardFilters.search) + '">' +
1717
- '<select data-filter="priority"><option value="">All Priorities</option>' +
1718
- PRIORITIES.map(function(p) { return '<option value="' + p + '"' + (boardFilters.priority === p ? ' selected' : '') + '>' + (PRIORITY_LABELS[p] || p) + '</option>'; }).join('') + '</select>' +
1719
- opts(ep, 'epic', epicPlural) + opts(ms, 'milestone', msPlural);
1771
+ var html = '<div id="board-' + tabConfig.id + '" class="board-container">';
1772
+ for (var ci = 0; ci < columns.length; ci++) {
1773
+ var col = columns[ci];
1774
+ var colItems = items.filter(function(item) { return (item[groupBy] || '') === col.key; });
1720
1775
 
1721
- c.querySelectorAll('input[data-filter]').forEach(function(el) {
1722
- el.addEventListener('input', function() { boardFilters[el.dataset.filter] = el.value; renderBoard(); });
1723
- });
1724
- c.querySelectorAll('select[data-filter]').forEach(function(el) {
1725
- el.addEventListener('change', function() { boardFilters[el.dataset.filter] = el.value; renderBoard(); });
1726
- });
1776
+ html += '<div class="column" data-status="' + escHtml(col.key) + '">';
1777
+ html += '<div class="column-header"><span class="col-title">' + statusIcon(entityType, col.key) + ' ' + escHtml(col.label) + '</span><span class="col-count">' + colItems.length + '</span></div>';
1778
+ html += '<div class="column-body">';
1727
1779
 
1728
- var btn = document.getElementById('board-create-btn');
1729
- if (btn) btn.addEventListener('click', function() { openCreateDialog('tasks'); });
1730
- }
1731
-
1732
- function getFilteredBoardTasks() {
1733
- var list = D.tasks.slice();
1734
- var f = boardFilters;
1735
- if (f.search) { var q = f.search.toLowerCase(); list = list.filter(function(x) { return (x.id || '').toLowerCase().indexOf(q) !== -1 || (x.title || '').toLowerCase().indexOf(q) !== -1; }); }
1736
- if (f.priority) list = list.filter(function(x) { return x.priority === f.priority; });
1737
- if (f.epic) list = list.filter(function(x) { return x.epic === f.epic; });
1738
- if (f.milestone) list = list.filter(function(x) { return x.milestone === f.milestone; });
1739
- return list;
1740
- }
1741
-
1742
- function renderBoard() {
1743
- var sprint = D.sprints.find(function(s) { return s.status === 'active'; });
1744
- var sbar = document.getElementById('sprint-bar');
1745
- if (sprint) {
1746
- var planned = sprint.planned_points || 0;
1747
- var completed = sprint.completed_points || 0;
1748
- var pct = planned ? Math.round(completed / planned * 100) : 0;
1749
- sbar.style.display = '';
1750
- sbar.innerHTML =
1751
- '<div class="sprint-goal"><b>' + escHtml(sprint.id || '') + '</b>' + (sprint.goal ? ' &mdash; ' + escHtml(sprint.goal) : '') + '</div>' +
1752
- '<div style="width:160px"><div class="progress progress-accent"><div class="progress-fill" style="width:' + pct + '%"></div></div>' +
1753
- '<div style="font-size:11px;color:var(--text3);margin-top:2px;font-family:var(--mono)">' + completed + '/' + planned + ' pts</div></div>' +
1754
- '<div class="sprint-dates">' + fmtDate(sprint.start_date) + ' &mdash; ' + fmtDate(sprint.end_date) + '</div>';
1755
- } else { sbar.style.display = 'none'; }
1756
-
1757
- var board = document.getElementById('board');
1758
- if (!D.loaded) {
1759
- board.innerHTML = BOARD_COLS.map(function() {
1760
- return '<div class="column"><div class="column-header"><div class="skeleton skeleton-line" style="width:80px;height:16px"></div></div><div class="column-body">' +
1761
- '<div class="skeleton skeleton-card"></div><div class="skeleton skeleton-card"></div></div></div>';
1762
- }).join('');
1763
- return;
1780
+ for (var ii = 0; ii < colItems.length; ii++) {
1781
+ html += renderBoardCard(colItems[ii], entityType, cardFields, tabConfig);
1782
+ }
1783
+
1784
+ html += '</div></div>';
1764
1785
  }
1786
+ html += '</div>';
1787
+ container.innerHTML = html;
1765
1788
 
1766
- var filteredTasks = getFilteredBoardTasks();
1767
- var taskSingular = ENTITY_NAMES.task ? ENTITY_NAMES.task.singular.toLowerCase() : 'task';
1789
+ // Setup drag-drop and click handlers
1790
+ setupBoardDragDrop(container, entityType, groupBy, tabConfig);
1791
+ }
1768
1792
 
1769
- if (!D.tasks.length) {
1770
- board.innerHTML = '<div class="empty"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="3"/><path d="M9 12h6M12 9v6"/></svg><p>No ' + escHtml(taskSingular) + 's yet. Create ' + escHtml(taskSingular) + ' files in your project/ directory to get started.</p></div>';
1771
- return;
1793
+ function renderBoardCard(item, entityType, cardFields, tabConfig) {
1794
+ var html = '<div class="card" draggable="true" data-id="' + escHtml(item.id || '') + '" data-status="' + escHtml(item.status || '') + '">';
1795
+
1796
+ // Header: ID + priority
1797
+ html += '<div class="card-header">';
1798
+ if (item.priority) html += '<span class="card-priority">' + priorityIcon(item.priority) + '</span>';
1799
+ html += '<span class="card-id">' + escHtml(item.id || '') + '</span>';
1800
+ html += '</div>';
1801
+
1802
+ // Title
1803
+ html += '<div class="card-title">' + escHtml(item.title || '') + '</div>';
1804
+
1805
+ // Meta fields
1806
+ html += '<div class="card-meta">';
1807
+ for (var i = 0; i < cardFields.length; i++) {
1808
+ var f = cardFields[i];
1809
+ if (f === 'id' || f === 'title' || f === 'status') continue;
1810
+ var val = item[f];
1811
+ if (val == null || val === '') continue;
1812
+
1813
+ if (f === 'assigned') {
1814
+ var arr = Array.isArray(val) ? val : [val];
1815
+ html += '<span class="card-assigned">' + escHtml(arr.join(', ')) + '</span>';
1816
+ } else if (f === 'points' && val != null) {
1817
+ html += '<span class="pill pill-points">' + val + ' pts</span>';
1818
+ } else {
1819
+ html += renderFieldPill(item, entityType, f);
1820
+ }
1772
1821
  }
1773
1822
 
1774
- board.innerHTML = BOARD_COLS.map(function(status) {
1775
- var cards = filteredTasks.filter(function(f) { return f.status === status; });
1776
- return '<div class="column" data-status="' + status + '">' +
1777
- '<div class="column-header"><span class="col-title">' + statusIcon(status) + ' ' + (STATUS_LABELS[status] || status) + '</span><span class="col-count">' + cards.length + '</span></div>' +
1778
- '<div class="column-body">' + cards.map(renderCard).join('') + '</div></div>';
1779
- }).join('');
1823
+ // Show ancestor refs as pills
1824
+ var ancestors = findAncestors(entityType);
1825
+ for (var ai = 0; ai < ancestors.length; ai++) {
1826
+ var ancVal = item['_' + ancestors[ai]];
1827
+ if (ancVal) {
1828
+ html += '<span class="pill" style="background:' + hashColor(ancVal) + ';color:#fff;font-size:10px">' + escHtml(ancVal) + '</span>';
1829
+ }
1830
+ }
1780
1831
 
1781
- setupDragDrop();
1832
+ html += '</div></div>';
1833
+ return html;
1782
1834
  }
1783
1835
 
1784
- function renderCard(f) {
1785
- var ec = f.epic ? epicColor(f.epic) : '';
1786
- var assigned = Array.isArray(f.assigned) ? f.assigned.join(', ') : (f.assigned || '');
1787
- return '<div class="card" draggable="true" data-id="' + escHtml(f.id || '') + '" data-status="' + escHtml(f.status || '') + '">' +
1788
- '<div class="card-header">' +
1789
- '<span class="card-priority">' + priorityIcon(f.priority) + '</span>' +
1790
- '<span class="card-id">' + escHtml(f.id || '') + '</span>' +
1791
- '</div>' +
1792
- '<div class="card-title">' + escHtml(f.title || '') + '</div>' +
1793
- '<div class="card-meta">' +
1794
- (f.points != null ? '<span class="pill pill-points">' + f.points + ' pts</span>' : '') +
1795
- (f.epic ? '<span class="pill pill-epic" style="background:' + ec + '">' + escHtml(f.epic) + '</span>' : '') +
1796
- (f.ai && Object.keys(f.ai).length > 0 ? '<span class="pill pill-ai" title="AI properties">&#9889;</span>' : '') +
1797
- (assigned ? '<span class="card-assigned">' + escHtml(assigned) + '</span>' : '') +
1798
- '</div></div>';
1799
- }
1800
-
1801
- /* ── Drag & Drop ─────────────────────────────────────────── */
1802
- function setupDragDrop() {
1803
- document.querySelectorAll('.card[draggable]').forEach(function(card) {
1836
+ function setupBoardDragDrop(container, entityType, groupBy, tabConfig) {
1837
+ var plural = getEntityPlural(entityType);
1838
+
1839
+ container.querySelectorAll('.card[draggable]').forEach(function(card) {
1804
1840
  card.addEventListener('dragstart', function(e) {
1805
1841
  e.dataTransfer.setData('text/plain', card.dataset.id);
1806
1842
  e.dataTransfer.effectAllowed = 'move';
@@ -1808,16 +1844,16 @@ function setupDragDrop() {
1808
1844
  });
1809
1845
  card.addEventListener('dragend', function() {
1810
1846
  card.classList.remove('dragging');
1811
- document.querySelectorAll('.column.drag-over').forEach(function(c) { c.classList.remove('drag-over'); });
1847
+ container.querySelectorAll('.column.drag-over').forEach(function(c) { c.classList.remove('drag-over'); });
1812
1848
  });
1813
1849
  card.addEventListener('click', function(e) {
1814
1850
  if (e.defaultPrevented) return;
1815
- var task = D.tasks.find(function(f) { return f.id === card.dataset.id; });
1816
- if (task) openPanel('tasks', task);
1851
+ var item = getEntities(entityType).find(function(i) { return i.id === card.dataset.id; });
1852
+ if (item) openPanel(entityType, item);
1817
1853
  });
1818
1854
  });
1819
1855
 
1820
- document.querySelectorAll('.column').forEach(function(col) {
1856
+ container.querySelectorAll('.column').forEach(function(col) {
1821
1857
  col.addEventListener('dragover', function(e) {
1822
1858
  e.preventDefault();
1823
1859
  e.dataTransfer.dropEffect = 'move';
@@ -1831,411 +1867,614 @@ function setupDragDrop() {
1831
1867
  col.addEventListener('drop', function(e) {
1832
1868
  e.preventDefault();
1833
1869
  col.classList.remove('drag-over');
1834
- var taskId = e.dataTransfer.getData('text/plain');
1835
- var newStatus = col.dataset.status;
1836
- if (!taskId || !newStatus) return;
1837
-
1838
- // Optimistic update
1839
- var task = D.tasks.find(function(f) { return f.id === taskId; });
1840
- if (task && task.status !== newStatus) {
1841
- task.status = newStatus;
1842
- renderBoard();
1843
- patchItem('tasks', taskId, { status: newStatus });
1870
+ var itemId = e.dataTransfer.getData('text/plain');
1871
+ var newValue = col.dataset.status;
1872
+ if (!itemId || !newValue) return;
1873
+
1874
+ var item = getEntities(entityType).find(function(i) { return i.id === itemId; });
1875
+ if (item && item[groupBy] !== newValue) {
1876
+ item[groupBy] = newValue;
1877
+ renderCurrentView();
1878
+ var updates = {};
1879
+ updates[groupBy] = newValue;
1880
+ patchItem(plural, itemId, updates);
1844
1881
  }
1845
1882
  });
1846
1883
  });
1847
1884
  }
1848
1885
 
1886
+
1849
1887
  /* ══════════════════════════════════════════════════════════════
1850
- TABLE VIEW
1888
+ TABLE VIEW — Generic sortable table from tabConfig
1851
1889
  ══════════════════════════════════════════════════════════════ */
1852
- var tableSort = { col: 'id', asc: true };
1853
- var tableFilters = { milestone: '', epic: '', status: '', sprint: '', search: '', priority: '' };
1854
- var expandedRow = null;
1855
- var TABLE_COLS = [
1856
- {key:'id',label:'ID'},{key:'title',label:'Title'},{key:'epic',label:'Epic'},
1857
- {key:'milestone',label:'Milestone'},{key:'sprint',label:'Sprint'},{key:'status',label:'Status'},
1858
- {key:'points',label:'Pts'},{key:'priority',label:'Priority'},{key:'assigned',label:'Assigned'}
1859
- ];
1860
-
1861
- function renderTableControls() {
1862
- var c = document.getElementById('table-controls');
1863
- var ms = [], ep = [], st = [], sp = [];
1864
- D.tasks.forEach(function(f) {
1865
- if (f.milestone && ms.indexOf(f.milestone) === -1) ms.push(f.milestone);
1866
- if (f.epic && ep.indexOf(f.epic) === -1) ep.push(f.epic);
1867
- if (f.status && st.indexOf(f.status) === -1) st.push(f.status);
1868
- if (f.sprint && sp.indexOf(f.sprint) === -1) sp.push(f.sprint);
1869
- });
1870
1890
 
1871
- var msPlural = ENTITY_NAMES.milestone ? ENTITY_NAMES.milestone.plural : 'Milestones';
1872
- var epicPlural = ENTITY_NAMES.epic ? ENTITY_NAMES.epic.plural : 'Epics';
1873
- var sprintPlural = ENTITY_NAMES.sprint ? ENTITY_NAMES.sprint.plural : 'Sprints';
1874
-
1875
- function opts(arr, filter, label) {
1876
- return '<select data-tf="' + filter + '"><option value="">All ' + label + '</option>' +
1877
- arr.map(function(v) { return '<option value="' + escHtml(v) + '"' + (tableFilters[filter] === v ? ' selected' : '') + '>' + escHtml(v) + '</option>'; }).join('') + '</select>';
1891
+ function renderTable(container, tabConfig) {
1892
+ if (!D.loaded || !tabConfig || !tabConfig.entity) {
1893
+ container.innerHTML = '<div class="loading-container">' +
1894
+ '<div class="skeleton skeleton-line" style="width:100%;height:32px"></div>'.repeat(6) + '</div>';
1895
+ return;
1878
1896
  }
1879
- var createBtn = isSourceWritable() ?
1880
- '<button class="btn btn-sm btn-create" id="table-create-btn">+ New ' + escHtml(ENTITY_NAMES.task ? ENTITY_NAMES.task.singular : 'Task') + '</button>' : '';
1881
1897
 
1882
- c.innerHTML = createBtn +
1883
- '<input type="text" data-tf="search" placeholder="Search ID or title..." value="' + escHtml(tableFilters.search) + '">' +
1884
- opts(ms, 'milestone', msPlural) + opts(ep, 'epic', epicPlural) + opts(st, 'status', 'Statuses') + opts(sp, 'sprint', sprintPlural) +
1885
- '<select data-tf="priority"><option value="">All Priorities</option>' +
1886
- PRIORITIES.map(function(p) { return '<option value="' + p + '"' + (tableFilters.priority === p ? ' selected' : '') + '>' + (PRIORITY_LABELS[p] || p) + '</option>'; }).join('') + '</select>';
1898
+ var entityType = tabConfig.entity;
1899
+ var tableConfig = tabConfig.table || {};
1900
+ var columns = tableConfig.columns || ['id', 'title', 'status'];
1901
+ var sortable = tableConfig.sortable !== false;
1902
+ var items = getFilteredItems(tabConfig);
1903
+ var tabId = tabConfig.id;
1887
1904
 
1888
- c.querySelector('input[data-tf="search"]').addEventListener('input', function(e) { tableFilters.search = e.target.value; renderTableBody(); });
1889
- c.querySelectorAll('select[data-tf]').forEach(function(sel) {
1890
- sel.addEventListener('change', function() { tableFilters[sel.dataset.tf] = sel.value; renderTableBody(); });
1891
- });
1892
-
1893
- var btn = document.getElementById('table-create-btn');
1894
- if (btn) btn.addEventListener('click', function() { openCreateDialog('tasks'); });
1895
- }
1896
-
1897
- function getFilteredTasks() {
1898
- var list = D.tasks.slice();
1899
- var f = tableFilters;
1900
- if (f.search) { var q = f.search.toLowerCase(); list = list.filter(function(x) { return (x.id || '').toLowerCase().indexOf(q) !== -1 || (x.title || '').toLowerCase().indexOf(q) !== -1; }); }
1901
- if (f.milestone) list = list.filter(function(x) { return x.milestone === f.milestone; });
1902
- if (f.epic) list = list.filter(function(x) { return x.epic === f.epic; });
1903
- if (f.status) list = list.filter(function(x) { return x.status === f.status; });
1904
- if (f.sprint) list = list.filter(function(x) { return x.sprint === f.sprint; });
1905
- if (f.priority) list = list.filter(function(x) { return x.priority === f.priority; });
1906
- var col = tableSort.col, dir = tableSort.asc ? 1 : -1;
1907
- list.sort(function(a, b) {
1908
- var va = a[col] == null ? '' : a[col], vb = b[col] == null ? '' : b[col];
1909
- if (typeof va === 'number' && typeof vb === 'number') return (va - vb) * dir;
1910
- return String(va).localeCompare(String(vb)) * dir;
1911
- });
1912
- return list;
1913
- }
1914
-
1915
- function renderTableBody() {
1916
- var w = document.getElementById('table-wrapper');
1917
- var taskPlural = ENTITY_NAMES.task ? ENTITY_NAMES.task.plural.toLowerCase() : 'tasks';
1918
- if (!D.loaded) { w.innerHTML = '<div class="loading-container">' + '<div class="skeleton skeleton-line" style="width:100%;height:32px"></div>'.repeat(6) + '</div>'; return; }
1919
- var tasks = getFilteredTasks();
1920
- if (!tasks.length) {
1921
- w.innerHTML = '<div class="empty"><p>' + (D.tasks.length ? 'No ' + escHtml(taskPlural) + ' match filters.' : 'No ' + escHtml(taskPlural) + ' yet.') + '</p></div>';
1905
+ if (items.length === 0) {
1906
+ var allItems = getEntities(entityType);
1907
+ container.innerHTML = '<div class="empty"><p>' +
1908
+ (allItems.length ? 'No items match filters.' : 'No ' + escHtml(getEntitySingular(entityType).toLowerCase()) + 's yet.') +
1909
+ '</p></div>';
1922
1910
  return;
1923
1911
  }
1912
+
1913
+ // Apply table sort
1914
+ var sort = D.tabSort[tabId] || { col: columns[0], asc: true };
1915
+ if (sort.col) {
1916
+ var col = sort.col, dir = sort.asc ? 1 : -1;
1917
+ items.sort(function(a, b) {
1918
+ var va = a[col] != null ? a[col] : (a['_' + col] || '');
1919
+ var vb = b[col] != null ? b[col] : (b['_' + col] || '');
1920
+ if (typeof va === 'number' && typeof vb === 'number') return (va - vb) * dir;
1921
+ return String(va).localeCompare(String(vb)) * dir;
1922
+ });
1923
+ }
1924
+
1924
1925
  var html = '<table class="ftable"><thead><tr>';
1925
- TABLE_COLS.forEach(function(c) {
1926
- var sorted = tableSort.col === c.key;
1927
- html += '<th class="' + (sorted ? 'sorted' : '') + '" data-col="' + c.key + '">' + c.label + '<span class="sort-arrow">' + (sorted ? (tableSort.asc ? '\u25B2' : '\u25BC') : '') + '</span></th>';
1928
- });
1926
+ for (var ci = 0; ci < columns.length; ci++) {
1927
+ var c = columns[ci];
1928
+ var sorted = sort.col === c;
1929
+ var label = c;
1930
+ // Get label from field def or entity name for ancestor refs
1931
+ var fieldDef = getEntityFields(entityType)[c];
1932
+ if (fieldDef && fieldDef.label) label = fieldDef.label;
1933
+ else if (c === 'id') label = 'ID';
1934
+ else if (c === 'title') label = 'Title';
1935
+ else {
1936
+ var ancDef = getEntityDef(c);
1937
+ if (ancDef) label = ancDef.singular;
1938
+ else label = c.charAt(0).toUpperCase() + c.slice(1);
1939
+ }
1940
+
1941
+ html += '<th class="' + (sorted ? 'sorted' : '') + '"' + (sortable ? ' data-col="' + c + '"' : '') + '>' +
1942
+ escHtml(label) + '<span class="sort-arrow">' + (sorted ? (sort.asc ? '\u25B2' : '\u25BC') : '') + '</span></th>';
1943
+ }
1929
1944
  html += '</tr></thead><tbody>';
1930
- tasks.forEach(function(f) {
1931
- var assigned = Array.isArray(f.assigned) ? f.assigned.join(', ') : (f.assigned || '');
1932
- html += '<tr class="clickable-row" data-fid="' + escHtml(f.id || '') + '">' +
1933
- '<td class="td-id">' + escHtml(f.id || '') + '</td>' +
1934
- '<td class="td-title">' + escHtml(f.title || '') + '</td>' +
1935
- '<td>' + (f.epic ? '<span class="pill pill-epic" style="background:' + epicColor(f.epic) + '">' + escHtml(f.epic) + '</span>' : '') + '</td>' +
1936
- '<td>' + escHtml(f.milestone || '') + '</td>' +
1937
- '<td>' + escHtml(f.sprint || '') + '</td>' +
1938
- '<td><span class="badge ' + badgeClass('badge', f.status) + '">' + statusIcon(f.status) + ' ' + escHtml(f.status || '') + '</span></td>' +
1939
- '<td>' + (f.points != null ? f.points : '') + '</td>' +
1940
- '<td>' + (f.priority ? '<span class="badge ' + badgeClass('badge', f.priority) + '">' + priorityIcon(f.priority) + ' ' + escHtml(f.priority) + '</span>' : '') + '</td>' +
1941
- '<td>' + escHtml(assigned) + '</td></tr>';
1942
- });
1943
- html += '</tbody></table>';
1944
- w.innerHTML = html;
1945
1945
 
1946
- w.querySelectorAll('th[data-col]').forEach(function(th) {
1947
- th.addEventListener('click', function() {
1948
- if (tableSort.col === th.dataset.col) tableSort.asc = !tableSort.asc;
1949
- else { tableSort.col = th.dataset.col; tableSort.asc = true; }
1950
- renderTableBody();
1946
+ for (var ii = 0; ii < items.length; ii++) {
1947
+ var item = items[ii];
1948
+ html += '<tr class="clickable-row" data-fid="' + escHtml(item.id || '') + '">';
1949
+ for (var cj = 0; cj < columns.length; cj++) {
1950
+ html += '<td>' + renderFieldCell(item, entityType, columns[cj]) + '</td>';
1951
+ }
1952
+ html += '</tr>';
1953
+ }
1954
+ html += '</tbody></table>';
1955
+ container.innerHTML = html;
1956
+
1957
+ // Sort handlers
1958
+ if (sortable) {
1959
+ container.querySelectorAll('th[data-col]').forEach(function(th) {
1960
+ th.addEventListener('click', function() {
1961
+ var s = D.tabSort[tabId] || { col: null, asc: true };
1962
+ if (s.col === th.dataset.col) s.asc = !s.asc;
1963
+ else { s.col = th.dataset.col; s.asc = true; }
1964
+ D.tabSort[tabId] = s;
1965
+ renderCurrentView();
1966
+ });
1951
1967
  });
1952
- });
1953
- w.querySelectorAll('.clickable-row').forEach(function(tr) {
1968
+ }
1969
+
1970
+ // Row click handlers
1971
+ container.querySelectorAll('.clickable-row').forEach(function(tr) {
1954
1972
  tr.addEventListener('click', function() {
1955
- var task = D.tasks.find(function(f) { return f.id === tr.dataset.fid; });
1956
- if (task) openPanel('tasks', task);
1973
+ var item = getEntities(entityType).find(function(i) { return i.id === tr.dataset.fid; });
1974
+ if (item) openPanel(entityType, item);
1957
1975
  });
1958
1976
  });
1959
1977
  }
1960
1978
 
1979
+
1961
1980
  /* ══════════════════════════════════════════════════════════════
1962
- MILESTONES VIEW
1981
+ CARDS VIEW — Generic card grid with expandChildren
1963
1982
  ══════════════════════════════════════════════════════════════ */
1964
- var msFilter = { status: '' };
1965
-
1966
- function renderMsFilters() {
1967
- var c = document.getElementById('ms-filters');
1968
- var msStatuses = (D.config && D.config.statuses && D.config.statuses.milestone) ?
1969
- D.config.statuses.milestone.map(function(s) { return s; }) :
1970
- [{key:'planned',label:'Planned'},{key:'active',label:'Active'},{key:'completed',label:'Completed'}];
1971
-
1972
- c.innerHTML = '<label>Filter:</label>' +
1973
- '<select data-msf="status"><option value="">All Statuses</option>' +
1974
- msStatuses.map(function(s) {
1975
- return '<option value="' + s.key + '"' + (msFilter.status === s.key ? ' selected' : '') + '>' + escHtml(s.label) + '</option>';
1976
- }).join('') + '</select>';
1977
- c.querySelector('select[data-msf]').addEventListener('change', function(e) { msFilter.status = e.target.value; renderMilestones(); });
1978
- }
1979
-
1980
- function renderMilestones() {
1981
- var c = document.getElementById('milestones-container');
1982
- var taskPlural = ENTITY_NAMES.task ? ENTITY_NAMES.task.plural.toLowerCase() : 'tasks';
1983
- var msDir = ENTITY_NAMES.milestone ? ENTITY_NAMES.milestone.dir : 'milestones';
1984
- var msPlural = ENTITY_NAMES.milestone ? ENTITY_NAMES.milestone.plural.toLowerCase() : 'milestones';
1985
- if (!D.loaded) { c.innerHTML = '<div class="loading-container"><div class="skeleton skeleton-card" style="height:200px"></div></div>'; return; }
1986
-
1987
- var milestones = D.milestones;
1988
- if (msFilter.status) milestones = milestones.filter(function(m) { return m.status === msFilter.status; });
1989
-
1990
- if (!milestones.length) {
1991
- c.innerHTML = '<div class="empty"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg><p>' + (D.milestones.length ? 'No ' + escHtml(msPlural) + ' match filter.' : 'No ' + escHtml(msPlural) + ' yet. Create milestone directories under project/' + escHtml(msDir) + '/ to get started.') + '</p></div>';
1983
+
1984
+ function renderCards(container, tabConfig) {
1985
+ if (!D.loaded || !tabConfig || !tabConfig.entity) {
1986
+ container.innerHTML = '<div class="loading-container">' +
1987
+ '<div class="skeleton skeleton-card" style="height:120px"></div>'.repeat(4) + '</div>';
1988
+ return;
1989
+ }
1990
+
1991
+ var entityType = tabConfig.entity;
1992
+ var cardsConfig = tabConfig.cards || {};
1993
+ var fields = cardsConfig.fields || ['title', 'status'];
1994
+ var showProgress = cardsConfig.showProgress;
1995
+ var expandChildren = cardsConfig.expandChildren;
1996
+ var items = getFilteredItems(tabConfig);
1997
+
1998
+ if (items.length === 0) {
1999
+ container.innerHTML = emptyState(entityType);
1992
2000
  return;
1993
2001
  }
1994
2002
 
1995
- c.innerHTML = milestones.map(function(ms) {
1996
- var pct = ms.progress || 0;
1997
- var fc = ms.featureCount || 0, cc = ms.completedCount || 0;
1998
- var msEpics = D.epics.filter(function(e) { return e.milestone === (ms.id || ms._dir); });
1999
- var epicCards = msEpics.length ? msEpics.map(function(e) {
2000
- var ep = e.progress || 0;
2001
- var ec = epicColor(e.id || e.title || '');
2002
- return '<div class="ms-epic" data-epic-id="' + escHtml(e.id || '') + '"><div class="epic-name"><span class="dot" style="background:' + ec + '"></span>' + escHtml(e.title || e.id || '') + '</div>' +
2003
- '<div class="epic-counts">' + (e.completedCount || 0) + ' / ' + (e.featureCount || 0) + ' ' + escHtml(taskPlural) + ' &middot; ' + (e.totalPoints || 0) + ' pts</div>' +
2004
- '<div class="progress progress-accent"><div class="progress-fill" style="width:' + ep + '%;background:' + ec + '"></div></div>' +
2005
- '<div class="epic-meta">' +
2006
- (e.status ? '<span class="badge ' + badgeClass('badge', e.status) + '">' + statusIcon(e.status) + ' ' + escHtml(e.status) + '</span>' : '') +
2007
- (e.priority ? '<span class="badge ' + badgeClass('badge', e.priority) + '">' + priorityIcon(e.priority) + ' ' + escHtml(e.priority) + '</span>' : '') +
2008
- '</div></div>';
2009
- }).join('') : '<div style="color:var(--text3);font-size:.82rem">No epics yet.</div>';
2010
-
2011
- return '<div class="ms-card" data-ms-id="' + escHtml(ms.id || '') + '">' +
2012
- '<div class="ms-header"><h2>' + milestoneIcon(ms.status) + ' ' + escHtml(ms.title || ms.id || '') + '</h2>' +
2013
- '<span class="badge ' + badgeClass('badge', ms.status) + '">' + escHtml(ms.status || '') + '</span>' +
2014
- (ms.deadline ? '<span class="ms-deadline">' + fmtDate(ms.deadline) + '</span>' : '') + '</div>' +
2015
- '<div class="ms-progress"><div class="progress-label"><span>' + cc + ' / ' + fc + ' ' + escHtml(taskPlural) + '</span><span>' + pct + '%</span></div>' +
2016
- '<div class="progress progress-lg progress-success"><div class="progress-fill" style="width:' + pct + '%"></div></div></div>' +
2017
- '<div class="ms-epics">' + epicCards + '</div></div>';
2018
- }).join('');
2019
-
2020
- // Click handlers for milestones
2021
- c.querySelectorAll('.ms-card[data-ms-id]').forEach(function(card) {
2003
+ var html = '<div class="cards-grid">';
2004
+ for (var i = 0; i < items.length; i++) {
2005
+ var item = items[i];
2006
+ html += renderEntityCard(item, entityType, fields, showProgress, expandChildren, tabConfig);
2007
+ }
2008
+ html += '</div>';
2009
+ container.innerHTML = html;
2010
+
2011
+ // Click handlers
2012
+ container.querySelectorAll('.entity-card[data-id]').forEach(function(card) {
2022
2013
  card.addEventListener('click', function(e) {
2023
- if (e.target.closest('.ms-epic')) return;
2024
- var ms = D.milestones.find(function(m) { return m.id === card.dataset.msId; });
2025
- if (ms) openPanel('milestones', ms);
2014
+ if (e.target.closest('.child-card')) return;
2015
+ var item = getEntities(entityType).find(function(i) { return i.id === card.dataset.id; });
2016
+ if (item) openPanel(entityType, item);
2026
2017
  });
2027
2018
  });
2028
2019
 
2029
- // Click handlers for epics
2030
- c.querySelectorAll('.ms-epic[data-epic-id]').forEach(function(card) {
2031
- card.addEventListener('click', function(e) {
2032
- e.stopPropagation();
2033
- var epic = D.epics.find(function(ep) { return ep.id === card.dataset.epicId; });
2034
- if (epic) openPanel('epics', epic);
2020
+ // Child card click handlers
2021
+ if (expandChildren) {
2022
+ container.querySelectorAll('.child-card[data-id]').forEach(function(card) {
2023
+ card.addEventListener('click', function(e) {
2024
+ e.stopPropagation();
2025
+ var childType = card.dataset.type;
2026
+ var childItem = getEntities(childType).find(function(i) { return i.id === card.dataset.id; });
2027
+ if (childItem) openPanel(childType, childItem);
2028
+ });
2035
2029
  });
2036
- });
2030
+ }
2037
2031
  }
2038
2032
 
2039
- /* ══════════════════════════════════════════════════════════════
2040
- METRICS VIEW
2041
- ══════════════════════════════════════════════════════════════ */
2042
- function renderMetrics() {
2043
- var c = document.getElementById('metrics-container');
2044
- var taskPlural = ENTITY_NAMES.task ? ENTITY_NAMES.task.plural : 'Tasks';
2045
- if (!D.loaded) { c.innerHTML = '<div class="metrics-grid">' + '<div class="skeleton skeleton-card" style="height:200px"></div>'.repeat(4) + '</div>'; return; }
2033
+ function renderEntityCard(item, entityType, fields, showProgress, expandChildren, tabConfig) {
2034
+ var html = '<div class="entity-card" data-id="' + escHtml(item.id || '') + '">';
2035
+
2036
+ // Header with status icon
2037
+ html += '<div class="entity-card-header">';
2038
+ if (item.status) html += '<span>' + statusIcon(entityType, item.status) + '</span>';
2039
+ html += '<span class="entity-card-id">' + escHtml(item.id || '') + '</span>';
2040
+ html += '</div>';
2041
+
2042
+ // Title
2043
+ html += '<div class="entity-card-title">' + escHtml(item.title || '') + '</div>';
2044
+
2045
+ // Fields as pills
2046
+ html += '<div class="entity-card-meta">';
2047
+ for (var i = 0; i < fields.length; i++) {
2048
+ var f = fields[i];
2049
+ if (f === 'title' || f === 'id') continue;
2050
+ if (f === 'progress' && item._progress != null) {
2051
+ html += '<span class="pill pill-points">' + item._progress + '%</span>';
2052
+ continue;
2053
+ }
2054
+ html += renderFieldPill(item, entityType, f);
2055
+ }
2056
+ html += '</div>';
2046
2057
 
2047
- var msProg = D.milestones.length ? D.milestones.map(function(ms) {
2048
- var pct = ms.progress || 0;
2058
+ // Progress bar
2059
+ if (showProgress && item._progress != null) {
2060
+ var pct = item._progress || 0;
2049
2061
  var clr = pct >= 100 ? 'var(--success)' : pct > 50 ? 'var(--warning)' : 'var(--accent)';
2050
- return '<div class="health-row"><div class="health-dot" style="background:' + clr + '"></div>' + milestoneIcon(ms.status) + '<div class="health-label">' + escHtml(ms.title || ms.id || '') + '</div><div class="health-val">' + pct + '%</div></div>';
2051
- }).join('') : '<div style="color:var(--text3);padding:8px 0">No milestones.</div>';
2062
+ html += '<div class="entity-card-progress">';
2063
+ html += '<div class="progress-label"><span>' + (item._completedCount || 0) + '/' + (item._childCount || 0) + '</span><span>' + pct + '%</span></div>';
2064
+ html += '<div class="progress progress-lg"><div class="progress-fill" style="width:' + pct + '%;background:' + clr + '"></div></div>';
2065
+ html += '</div>';
2066
+ }
2052
2067
 
2053
- var statuses = {};
2054
- D.tasks.forEach(function(f) { var s = f.status || 'unknown'; statuses[s] = (statuses[s] || 0) + 1; });
2055
- var statusHtml = Object.keys(statuses).map(function(s) {
2056
- return '<div class="health-row">' + statusIcon(s) + '<div class="health-label" style="text-transform:capitalize">' + escHtml(STATUS_LABELS[s] || s) + '</div><div class="health-val">' + statuses[s] + '</div></div>';
2057
- }).join('') || '<div style="color:var(--text3);padding:8px 0">No data.</div>';
2068
+ // Expand children
2069
+ if (expandChildren && expandChildren.entity) {
2070
+ var childType = expandChildren.entity;
2071
+ var childFields = expandChildren.fields || ['title'];
2072
+ var childShowProgress = expandChildren.showProgress;
2073
+
2074
+ // Find children that belong to this parent
2075
+ var children = getEntities(childType).filter(function(child) {
2076
+ return child['_' + entityType] === item.id ||
2077
+ child['_' + entityType] === (item._dir || item.id);
2078
+ });
2058
2079
 
2059
- var priorities = {};
2060
- D.tasks.forEach(function(f) { var p = f.priority || 'none'; priorities[p] = (priorities[p] || 0) + 1; });
2061
- var priorityHtml = Object.keys(priorities).map(function(p) {
2062
- return '<div class="health-row">' + priorityIcon(p) + '<div class="health-label" style="text-transform:capitalize">' + escHtml(PRIORITY_LABELS[p] || p) + '</div><div class="health-val">' + priorities[p] + '</div></div>';
2063
- }).join('') || '<div style="color:var(--text3);padding:8px 0">No data.</div>';
2080
+ if (children.length > 0) {
2081
+ html += '<div class="entity-card-children">';
2082
+ for (var ci = 0; ci < children.length; ci++) {
2083
+ var child = children[ci];
2084
+ html += '<div class="child-card" data-id="' + escHtml(child.id || '') + '" data-type="' + escHtml(childType) + '">';
2085
+ html += '<div class="child-card-header">';
2086
+ if (child.status || child.hill) {
2087
+ var statusField = child.status ? 'status' : 'hill';
2088
+ html += statusIcon(childType, child[statusField] || child.status);
2089
+ }
2090
+ html += '<span class="child-card-title">' + escHtml(child.title || child.id || '') + '</span>';
2091
+ html += '</div>';
2064
2092
 
2065
- var h = D.health || {};
2066
- var qualHtml = '<div class="health-row"><div class="health-dot" style="background:var(--text2)"></div><div class="health-label">Total ' + escHtml(taskPlural) + '</div><div class="health-val">' + (h.totalFeatures || D.tasks.length) + '</div></div>' +
2067
- '<div class="health-row"><div class="health-dot" style="background:var(--success)"></div><div class="health-label">Completed</div><div class="health-val">' + (h.completedFeatures || 0) + '</div></div>' +
2068
- '<div class="health-row"><div class="health-dot" style="background:var(--warning)"></div><div class="health-label">In Progress</div><div class="health-val">' + (h.inProgressFeatures || 0) + '</div></div>' +
2069
- '<div class="health-row"><div class="health-dot" style="background:var(--accent)"></div><div class="health-label">Avg Velocity</div><div class="health-val">' + (h.velocity != null ? h.velocity + '%' : 'N/A') + '</div></div>';
2093
+ // Child fields
2094
+ for (var fi = 0; fi < childFields.length; fi++) {
2095
+ var cf = childFields[fi];
2096
+ if (cf === 'title') continue;
2097
+ if (cf === 'hill' && child.hill) {
2098
+ html += renderFieldPill(child, childType, 'hill');
2099
+ } else {
2100
+ html += renderFieldPill(child, childType, cf);
2101
+ }
2102
+ }
2070
2103
 
2071
- c.innerHTML = '<div class="metrics-grid">' +
2072
- '<div class="metric-card"><h3>Milestone Progress</h3>' + msProg + '</div>' +
2073
- '<div class="metric-card"><h3>Status Breakdown</h3>' + statusHtml + '</div>' +
2074
- '<div class="metric-card"><h3>Priority Breakdown</h3>' + priorityHtml + '</div>' +
2075
- '<div class="metric-card"><h3>Project Health</h3>' + qualHtml + '</div></div>';
2076
- }
2104
+ // Child progress
2105
+ if (childShowProgress && child._progress != null) {
2106
+ var cpct = child._progress || 0;
2107
+ var cclr = cpct >= 100 ? 'var(--success)' : cpct > 50 ? 'var(--warning)' : 'var(--accent)';
2108
+ html += '<div class="progress" style="margin-top:4px"><div class="progress-fill" style="width:' + cpct + '%;background:' + cclr + '"></div></div>';
2109
+ }
2077
2110
 
2078
- /* ══════════════════════════════════════════════════════════════
2079
- OVERVIEW VIEW
2080
- ══════════════════════════════════════════════════════════════ */
2081
- async function renderOverview() {
2082
- var c = document.getElementById('overview-container');
2083
- if (!D.loaded) { c.innerHTML = '<div class="loading-container"><div class="skeleton skeleton-card" style="height:200px"></div></div>'; return; }
2084
-
2085
- // Fetch overview data
2086
- var overviewMs = await fetchJson('/api/overview/milestones') || [];
2087
- var overviewLinks = await fetchJson('/api/overview/links');
2088
- var overviewMetrics = await fetchJson('/api/overview/metrics');
2089
-
2090
- D.overviewLinks = overviewLinks;
2091
-
2092
- var html = '<h2 style="margin-bottom:16px;font-size:16px;font-weight:700">Workspace Overview</h2>';
2093
-
2094
- // Global Milestones
2095
- html += '<h3 style="font-size:13px;text-transform:uppercase;letter-spacing:.04em;color:var(--text2);font-weight:600;margin:16px 0 8px">Global Milestones</h3>';
2096
- if (overviewMs.length > 0) {
2097
- html += overviewMs.map(function(ms) {
2098
- var pct = ms.combinedProgress != null ? ms.combinedProgress : (ms.progress || 0);
2099
- var tracked = ms.tracked || [];
2100
- var trackedHtml = '';
2101
- if (tracked.length > 0) {
2102
- trackedHtml = '<div class="tracked-ms"><div class="tracked-ms-header">Tracked Sub-Milestones</div>' +
2103
- tracked.map(function(t) {
2104
- return '<div class="tracked-ms-item"><span class="dot" style="background:' + (t.sourceColor || 'var(--accent)') + '"></span>' +
2105
- '<span style="font-size:12px;flex:1">' + escHtml(t.title || t.id || '') + '</span>' +
2106
- '<div class="progress progress-accent" style="width:80px"><div class="progress-fill" style="width:' + t.progress + '%;background:' + (t.sourceColor || 'var(--accent)') + '"></div></div>' +
2107
- '<span class="tracked-ms-pct">' + t.progress + '%</span></div>';
2108
- }).join('') + '</div>';
2111
+ html += '</div>';
2109
2112
  }
2110
- return '<div class="ms-card">' +
2111
- '<div class="ms-header"><h2>' + milestoneIcon(ms.status) + ' ' + escHtml(ms.title || ms.id || '') + '</h2>' +
2112
- '<span class="badge ' + badgeClass('badge', ms.status) + '">' + escHtml(ms.status || '') + '</span>' +
2113
- (ms.deadline ? '<span class="ms-deadline">' + fmtDate(ms.deadline) + '</span>' : '') + '</div>' +
2114
- '<div class="ms-progress"><div class="progress-label"><span>' + (ms.completedCount || 0) + ' / ' + (ms.featureCount || 0) + ' tasks</span><span>' + pct + '%</span></div>' +
2115
- '<div class="progress progress-lg progress-success"><div class="progress-fill" style="width:' + pct + '%"></div></div></div>' +
2116
- trackedHtml + '</div>';
2117
- }).join('');
2118
- } else {
2119
- html += '<div style="color:var(--text3);padding:8px 0">No milestones found across sources.</div>';
2120
- }
2121
-
2122
- // Cross-project links
2123
- if (overviewLinks && overviewLinks.links && overviewLinks.links.length > 0) {
2124
- html += '<h3 style="font-size:13px;text-transform:uppercase;letter-spacing:.04em;color:var(--text2);font-weight:600;margin:24px 0 8px">Cross-Project Links</h3>';
2125
- html += '<div class="metrics-grid">';
2126
- var linkGroups = {};
2127
- overviewLinks.links.forEach(function(l) {
2128
- var key = (l.fromSource || 'unknown');
2129
- if (!linkGroups[key]) linkGroups[key] = [];
2130
- linkGroups[key].push(l);
2131
- });
2132
- Object.keys(linkGroups).forEach(function(src) {
2133
- var links = linkGroups[src];
2134
- html += '<div class="metric-card"><h3>' + escHtml(src) + ' Links</h3>';
2135
- html += links.map(function(l) {
2136
- return '<div class="health-row"><span style="font-size:12px;font-family:var(--mono)">' + escHtml(l.from || '') + '</span>' +
2137
- '<span style="color:var(--text3);margin:0 4px">&rarr;</span>' +
2138
- '<span class="link-chip" data-link="' + escHtml(l.to || '') + '">' +
2139
- '<span class="link-chip-dot" style="background:var(--accent)"></span>' + escHtml(l.to || '') + '</span></div>';
2140
- }).join('');
2141
2113
  html += '</div>';
2142
- });
2143
- html += '</div>';
2144
- }
2145
-
2146
- // Source metrics
2147
- if (overviewMetrics && overviewMetrics.sources) {
2148
- html += '<h3 style="font-size:13px;text-transform:uppercase;letter-spacing:.04em;color:var(--text2);font-weight:600;margin:24px 0 8px">Source Metrics</h3>';
2149
- html += '<div class="metrics-grid">';
2150
- Object.keys(overviewMetrics.sources).forEach(function(key) {
2151
- var m = overviewMetrics.sources[key];
2152
- var pct = m.totalTasks > 0 ? Math.round((m.completedTasks / m.totalTasks) * 100) : 0;
2153
- html += '<div class="metric-card" style="border-left:3px solid ' + (m.color || 'var(--accent)') + '">' +
2154
- '<h3>' + escHtml(m.label || key) + '</h3>' +
2155
- '<div class="health-row"><div class="health-label">Tasks</div><div class="health-val">' + m.totalTasks + '</div></div>' +
2156
- '<div class="health-row"><div class="health-label">Completed</div><div class="health-val">' + m.completedTasks + '</div></div>' +
2157
- '<div class="health-row"><div class="health-label">Points</div><div class="health-val">' + m.totalPoints + '</div></div>' +
2158
- '<div class="progress progress-lg progress-success" style="margin-top:8px"><div class="progress-fill" style="width:' + pct + '%;background:' + (m.color || 'var(--success)') + '"></div></div>' +
2159
- '</div>';
2160
- });
2161
- html += '</div>';
2114
+ }
2162
2115
  }
2163
2116
 
2164
- c.innerHTML = html;
2165
-
2166
- // Click handlers for link chips
2167
- c.querySelectorAll('.link-chip[data-link]').forEach(function(chip) {
2168
- chip.addEventListener('click', function() {
2169
- var ref = chip.dataset.link;
2170
- var parts = ref.split(':');
2171
- if (parts.length === 2) {
2172
- switchSource(parts[0]);
2173
- // After switch, try to find and open the item
2174
- setTimeout(function() {
2175
- var task = D.tasks.find(function(t) { return t.id === ref || t.id === parts[1]; });
2176
- if (task) openPanel('tasks', task);
2177
- }, 500);
2178
- }
2179
- });
2180
- });
2117
+ html += '</div>';
2118
+ return html;
2181
2119
  }
2182
2120
 
2121
+
2183
2122
  /* ══════════════════════════════════════════════════════════════
2184
- NOTES VIEW — Notion-style split layout
2123
+ LIST VIEW — Expandable list with children
2185
2124
  ══════════════════════════════════════════════════════════════ */
2186
2125
 
2187
- var notesState = { notes: [], activeId: null, editor: null, dirty: false };
2126
+ function renderList(container, tabConfig) {
2127
+ if (!D.loaded || !tabConfig || !tabConfig.entity) {
2128
+ container.innerHTML = '<div class="loading-container">' +
2129
+ '<div class="skeleton skeleton-line" style="width:100%;height:48px;margin-bottom:8px"></div>'.repeat(4) + '</div>';
2130
+ return;
2131
+ }
2188
2132
 
2189
- function renderNotes() {
2190
- var container = document.getElementById('notes-container');
2191
- if (!container) return;
2133
+ var entityType = tabConfig.entity;
2134
+ var listConfig = tabConfig.list || {};
2135
+ var fields = listConfig.fields || ['title', 'status'];
2136
+ var expandChildren = listConfig.expandChildren;
2137
+ var items = getFilteredItems(tabConfig);
2192
2138
 
2193
- container.innerHTML =
2194
- '<div class="notes-layout">' +
2195
- '<div class="notes-sidebar">' +
2196
- '<div class="notes-toolbar">' +
2197
- '<input type="text" placeholder="Search..." id="notes-search">' +
2198
- '<button class="btn btn-sm btn-create" id="notes-new">+ New</button>' +
2199
- '</div>' +
2200
- '<div class="notes-list" id="notes-list"></div>' +
2201
- '</div>' +
2202
- '<div class="notes-editor" id="notes-editor-pane">' +
2203
- '<div class="notes-empty-state">' +
2204
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width:48px;height:48px;opacity:.3;margin-bottom:12px"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>' +
2205
- '<p>Select a note or create a new one</p>' +
2206
- '<p style="font-size:12px;margin-top:4px;color:var(--text3)">Use <kbd>/</kbd> for block commands</p>' +
2207
- '</div>' +
2208
- '</div>' +
2209
- '</div>';
2139
+ if (items.length === 0) {
2140
+ container.innerHTML = emptyState(entityType);
2141
+ return;
2142
+ }
2210
2143
 
2211
- loadNoteList();
2144
+ var html = '<div class="entity-list">';
2145
+ for (var i = 0; i < items.length; i++) {
2146
+ var item = items[i];
2147
+ html += renderListItem(item, entityType, fields, expandChildren, tabConfig);
2148
+ }
2149
+ html += '</div>';
2150
+ container.innerHTML = html;
2151
+
2152
+ // Click handlers for main items
2153
+ container.querySelectorAll('.entity-list-item[data-id]').forEach(function(el) {
2154
+ el.addEventListener('click', function(e) {
2155
+ if (e.target.closest('.child-list-item')) return;
2156
+ if (e.target.closest('.list-expand-btn')) return;
2157
+ var item = getEntities(entityType).find(function(i) { return i.id === el.dataset.id; });
2158
+ if (item) openPanel(entityType, item);
2159
+ });
2160
+ });
2212
2161
 
2213
- document.getElementById('notes-new').addEventListener('click', createNewNote);
2214
- document.getElementById('notes-search').addEventListener('input', function() {
2215
- renderNoteList(this.value.toLowerCase());
2162
+ // Expand toggle handlers
2163
+ container.querySelectorAll('.list-expand-btn').forEach(function(btn) {
2164
+ btn.addEventListener('click', function(e) {
2165
+ e.stopPropagation();
2166
+ var children = btn.closest('.entity-list-item').querySelector('.list-children');
2167
+ if (children) {
2168
+ var hidden = children.style.display === 'none';
2169
+ children.style.display = hidden ? '' : 'none';
2170
+ btn.textContent = hidden ? '\u25BC' : '\u25B6';
2171
+ }
2172
+ });
2216
2173
  });
2217
- }
2218
2174
 
2219
- async function loadNoteList() {
2220
- var base = apiBase();
2221
- var notes = await fetchJson(base + '/notes');
2222
- notesState.notes = notes || [];
2223
- renderNoteList('');
2175
+ // Child click handlers
2176
+ if (expandChildren) {
2177
+ container.querySelectorAll('.child-list-item[data-id]').forEach(function(el) {
2178
+ el.addEventListener('click', function(e) {
2179
+ e.stopPropagation();
2180
+ var childType = el.dataset.type;
2181
+ var childItem = getEntities(childType).find(function(i) { return i.id === el.dataset.id; });
2182
+ if (childItem) openPanel(childType, childItem);
2183
+ });
2184
+ });
2185
+ }
2224
2186
  }
2225
2187
 
2226
- function renderNoteList(filter) {
2227
- var list = document.getElementById('notes-list');
2228
- if (!list) return;
2229
-
2230
- var notes = notesState.notes;
2231
- if (filter) {
2232
- notes = notes.filter(function(n) {
2233
- return (n.title || '').toLowerCase().indexOf(filter) !== -1;
2188
+ function renderListItem(item, entityType, fields, expandChildren, tabConfig) {
2189
+ var hasChildren = false;
2190
+ var children = [];
2191
+ if (expandChildren && expandChildren.entity) {
2192
+ var childType = expandChildren.entity;
2193
+ children = getEntities(childType).filter(function(child) {
2194
+ return child['_' + entityType] === item.id ||
2195
+ child['_' + entityType] === (item._dir || item.id);
2234
2196
  });
2197
+ hasChildren = children.length > 0;
2235
2198
  }
2236
2199
 
2237
- notes.sort(function(a, b) {
2238
- return (b.updated || b.created || '').localeCompare(a.updated || a.created || '');
2200
+ var html = '<div class="entity-list-item" data-id="' + escHtml(item.id || '') + '">';
2201
+ html += '<div class="list-item-main">';
2202
+
2203
+ // Expand button
2204
+ if (hasChildren) {
2205
+ html += '<button class="list-expand-btn">\u25B6</button>';
2206
+ } else if (expandChildren) {
2207
+ html += '<span style="width:20px;display:inline-block"></span>';
2208
+ }
2209
+
2210
+ // Status icon
2211
+ if (item.status) {
2212
+ html += '<span class="list-item-status">' + statusIcon(entityType, item.status) + '</span>';
2213
+ }
2214
+
2215
+ // Title
2216
+ html += '<span class="list-item-title">' + escHtml(item.title || item.id || '') + '</span>';
2217
+
2218
+ // Fields
2219
+ html += '<span class="list-item-fields">';
2220
+ for (var i = 0; i < fields.length; i++) {
2221
+ var f = fields[i];
2222
+ if (f === 'title' || f === 'id') continue;
2223
+ if (f === 'progress' && item._progress != null) {
2224
+ var pct = item._progress || 0;
2225
+ var clr = pct >= 100 ? 'var(--success)' : pct > 50 ? 'var(--warning)' : 'var(--accent)';
2226
+ html += '<span class="list-item-progress"><div class="progress" style="width:80px"><div class="progress-fill" style="width:' + pct + '%;background:' + clr + '"></div></div><span class="pill pill-points">' + pct + '%</span></span>';
2227
+ continue;
2228
+ }
2229
+ html += renderFieldPill(item, entityType, f);
2230
+ }
2231
+ html += '</span>';
2232
+ html += '</div>';
2233
+
2234
+ // Children
2235
+ if (hasChildren && expandChildren) {
2236
+ var childType = expandChildren.entity;
2237
+ var childFields = expandChildren.fields || ['title', 'status'];
2238
+ html += '<div class="list-children" style="display:none">';
2239
+ for (var ci = 0; ci < children.length; ci++) {
2240
+ var child = children[ci];
2241
+ html += '<div class="child-list-item" data-id="' + escHtml(child.id || '') + '" data-type="' + escHtml(childType) + '">';
2242
+ if (child.status) html += statusIcon(childType, child.status);
2243
+ html += '<span class="child-list-title">' + escHtml(child.title || child.id || '') + '</span>';
2244
+ for (var fi = 0; fi < childFields.length; fi++) {
2245
+ var cf = childFields[fi];
2246
+ if (cf === 'title') continue;
2247
+ html += renderFieldPill(child, childType, cf);
2248
+ }
2249
+ html += '</div>';
2250
+ }
2251
+ html += '</div>';
2252
+ }
2253
+
2254
+ html += '</div>';
2255
+ return html;
2256
+ }
2257
+
2258
+
2259
+ /* ══════════════════════════════════════════════════════════════
2260
+ METRICS VIEW — Config-driven metric cards
2261
+ ══════════════════════════════════════════════════════════════ */
2262
+
2263
+ function renderMetrics(container, tabConfig) {
2264
+ if (!D.loaded) {
2265
+ container.innerHTML = '<div class="metrics-grid">' +
2266
+ '<div class="skeleton skeleton-card" style="height:200px"></div>'.repeat(4) + '</div>';
2267
+ return;
2268
+ }
2269
+
2270
+ var metricsConfig = tabConfig && tabConfig.metrics ? tabConfig.metrics : null;
2271
+ var cards = metricsConfig && metricsConfig.cards ? metricsConfig.cards : [];
2272
+
2273
+ // If no config, render default cards
2274
+ if (cards.length === 0) {
2275
+ cards = [
2276
+ { title: 'Status Breakdown', type: 'status-breakdown', entity: 'task' },
2277
+ { title: 'Project Health', type: 'health' }
2278
+ ];
2279
+ }
2280
+
2281
+ var html = '<div class="metrics-grid">';
2282
+ for (var i = 0; i < cards.length; i++) {
2283
+ html += renderMetricCard(cards[i]);
2284
+ }
2285
+ html += '</div>';
2286
+ container.innerHTML = html;
2287
+ }
2288
+
2289
+ function renderMetricCard(cardConfig) {
2290
+ var html = '<div class="metric-card"><h3>' + escHtml(cardConfig.title) + '</h3>';
2291
+
2292
+ switch (cardConfig.type) {
2293
+ case 'progress-by-parent':
2294
+ html += renderProgressByParent(cardConfig);
2295
+ break;
2296
+ case 'status-breakdown':
2297
+ html += renderStatusBreakdown(cardConfig);
2298
+ break;
2299
+ case 'health':
2300
+ html += renderHealthCard(cardConfig);
2301
+ break;
2302
+ case 'hill-chart':
2303
+ html += renderHillChart(cardConfig);
2304
+ break;
2305
+ default:
2306
+ html += '<div style="color:var(--text3);padding:8px 0">Unknown card type: ' + escHtml(cardConfig.type) + '</div>';
2307
+ }
2308
+
2309
+ html += '</div>';
2310
+ return html;
2311
+ }
2312
+
2313
+ function renderProgressByParent(config) {
2314
+ var parentType = config.parent;
2315
+ if (!parentType) return '<div style="color:var(--text3)">No parent type configured.</div>';
2316
+
2317
+ var parents = getEntities(parentType);
2318
+ if (parents.length === 0) {
2319
+ var singular = getEntitySingular(parentType);
2320
+ return '<div style="color:var(--text3);padding:8px 0">No ' + escHtml(singular.toLowerCase()) + 's.</div>';
2321
+ }
2322
+
2323
+ var html = '';
2324
+ for (var i = 0; i < parents.length; i++) {
2325
+ var p = parents[i];
2326
+ var pct = p._progress || 0;
2327
+ var clr = pct >= 100 ? 'var(--success)' : pct > 50 ? 'var(--warning)' : 'var(--accent)';
2328
+ html += '<div class="health-row">' +
2329
+ '<div class="health-dot" style="background:' + clr + '"></div>' +
2330
+ statusIcon(parentType, p.status) +
2331
+ '<div class="health-label">' + escHtml(p.title || p.id || '') + '</div>' +
2332
+ '<div class="health-val">' + pct + '%</div>' +
2333
+ '</div>';
2334
+ }
2335
+ return html;
2336
+ }
2337
+
2338
+ function renderStatusBreakdown(config) {
2339
+ var entityType = config.entity;
2340
+ if (!entityType) return '<div style="color:var(--text3)">No entity type configured.</div>';
2341
+
2342
+ var items = getEntities(entityType);
2343
+ var statuses = {};
2344
+ items.forEach(function(item) {
2345
+ var s = item.status || 'unknown';
2346
+ statuses[s] = (statuses[s] || 0) + 1;
2347
+ });
2348
+
2349
+ var keys = Object.keys(statuses);
2350
+ if (keys.length === 0) return '<div style="color:var(--text3);padding:8px 0">No data.</div>';
2351
+
2352
+ var html = '';
2353
+ for (var i = 0; i < keys.length; i++) {
2354
+ var s = keys[i];
2355
+ html += '<div class="health-row">' +
2356
+ statusIcon(entityType, s) +
2357
+ '<div class="health-label" style="text-transform:capitalize">' + escHtml(getFieldLabel(entityType, 'status', s)) + '</div>' +
2358
+ '<div class="health-val">' + statuses[s] + '</div>' +
2359
+ '</div>';
2360
+ }
2361
+ return html;
2362
+ }
2363
+
2364
+ function renderHealthCard() {
2365
+ var h = D.health || {};
2366
+ var totals = h.totals || {};
2367
+ var total = totals.total || 0;
2368
+ var completed = totals.completed || 0;
2369
+ var inProgress = totals.inProgress || 0;
2370
+
2371
+ return '<div class="health-row"><div class="health-dot" style="background:var(--text2)"></div><div class="health-label">Total Items</div><div class="health-val">' + total + '</div></div>' +
2372
+ '<div class="health-row"><div class="health-dot" style="background:var(--success)"></div><div class="health-label">Completed</div><div class="health-val">' + completed + '</div></div>' +
2373
+ '<div class="health-row"><div class="health-dot" style="background:var(--warning)"></div><div class="health-label">In Progress</div><div class="health-val">' + inProgress + '</div></div>' +
2374
+ '<div class="health-row"><div class="health-dot" style="background:var(--accent)"></div><div class="health-label">Progress</div><div class="health-val">' + (total > 0 ? Math.round(completed / total * 100) + '%' : 'N/A') + '</div></div>';
2375
+ }
2376
+
2377
+ function renderHillChart(config) {
2378
+ var entityType = config.entity;
2379
+ if (!entityType) return '<div style="color:var(--text3)">No entity type configured.</div>';
2380
+
2381
+ var items = getEntities(entityType);
2382
+ if (items.length === 0) {
2383
+ return '<div style="color:var(--text3);padding:8px 0">No ' + escHtml(getEntitySingular(entityType).toLowerCase()) + 's.</div>';
2384
+ }
2385
+
2386
+ // Hill chart: figuring out (uphill) → making it happen (downhill) → done
2387
+ var fields = getEntityFields(entityType);
2388
+ var hillField = fields.hill;
2389
+ if (!hillField || !hillField.values) {
2390
+ return '<div style="color:var(--text3);padding:8px 0">No hill field defined.</div>';
2391
+ }
2392
+
2393
+ var html = '';
2394
+ for (var i = 0; i < items.length; i++) {
2395
+ var item = items[i];
2396
+ var hillVal = item.hill || 'unknown';
2397
+ html += '<div class="health-row">' +
2398
+ statusIcon(entityType, hillVal) +
2399
+ '<div class="health-label">' + escHtml(item.title || item.id || '') + '</div>' +
2400
+ '<div class="health-val" style="font-size:11px">' + escHtml(getFieldLabel(entityType, 'hill', hillVal)) + '</div>' +
2401
+ '</div>';
2402
+ }
2403
+ return html;
2404
+ }
2405
+
2406
+
2407
+
2408
+ /* ══════════════════════════════════════════════════════════════
2409
+ NOTES VIEW — Notion-style split layout (config-driven)
2410
+ ══════════════════════════════════════════════════════════════ */
2411
+
2412
+ var notesState = { notes: [], activeId: null, editor: null, dirty: false, entityType: 'note' };
2413
+
2414
+ function renderNotes(container, tabConfig) {
2415
+ if (!container) return;
2416
+ var entityType = tabConfig && tabConfig.entity ? tabConfig.entity : 'note';
2417
+ notesState.entityType = entityType;
2418
+ var plural = getEntityPlural(entityType);
2419
+
2420
+ // Notes view needs special full-height layout
2421
+ var viewEl = container.closest('.view');
2422
+ if (viewEl) {
2423
+ viewEl.style.display = 'flex';
2424
+ viewEl.style.flexDirection = 'column';
2425
+ viewEl.style.margin = '-20px -24px';
2426
+ viewEl.style.height = 'calc(100% + 40px)';
2427
+ }
2428
+ container.style.flex = '1';
2429
+ container.style.overflow = 'hidden';
2430
+
2431
+ container.innerHTML =
2432
+ '<div class="notes-layout">' +
2433
+ '<div class="notes-sidebar">' +
2434
+ '<div class="notes-toolbar">' +
2435
+ '<input type="text" placeholder="Search..." id="notes-search">' +
2436
+ '<button class="btn btn-sm btn-create" id="notes-new">+ New</button>' +
2437
+ '</div>' +
2438
+ '<div class="notes-list" id="notes-list"></div>' +
2439
+ '</div>' +
2440
+ '<div class="notes-editor" id="notes-editor-pane">' +
2441
+ '<div class="notes-empty-state">' +
2442
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width:48px;height:48px;opacity:.3;margin-bottom:12px"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>' +
2443
+ '<p>Select a note or create a new one</p>' +
2444
+ '<p style="font-size:12px;margin-top:4px;color:var(--text3)">Use <kbd>/</kbd> for block commands</p>' +
2445
+ '</div>' +
2446
+ '</div>' +
2447
+ '</div>';
2448
+
2449
+ loadNoteList();
2450
+
2451
+ document.getElementById('notes-new').addEventListener('click', createNewNote);
2452
+ document.getElementById('notes-search').addEventListener('input', function() {
2453
+ renderNoteList(this.value.toLowerCase());
2454
+ });
2455
+ }
2456
+
2457
+ async function loadNoteList() {
2458
+ var base = apiBase();
2459
+ var plural = getEntityPlural(notesState.entityType);
2460
+ var notes = await fetchJson(base + '/' + plural);
2461
+ notesState.notes = notes || [];
2462
+ renderNoteList('');
2463
+ }
2464
+
2465
+ function renderNoteList(filter) {
2466
+ var list = document.getElementById('notes-list');
2467
+ if (!list) return;
2468
+
2469
+ var notes = notesState.notes;
2470
+ if (filter) {
2471
+ notes = notes.filter(function(n) {
2472
+ return (n.title || '').toLowerCase().indexOf(filter) !== -1;
2473
+ });
2474
+ }
2475
+
2476
+ notes.sort(function(a, b) {
2477
+ return (b.updated || b.created || '').localeCompare(a.updated || a.created || '');
2239
2478
  });
2240
2479
 
2241
2480
  if (notes.length === 0) {
@@ -2276,7 +2515,8 @@ async function selectNote(id) {
2276
2515
  pane.innerHTML = '<div class="notes-editor-loading">Loading...</div>';
2277
2516
 
2278
2517
  var base = apiBase();
2279
- var note = await fetchJson(base + '/notes/' + encodeURIComponent(id));
2518
+ var plural = getEntityPlural(notesState.entityType);
2519
+ var note = await fetchJson(base + '/' + plural + '/' + encodeURIComponent(id));
2280
2520
  if (!note) {
2281
2521
  pane.innerHTML = '<div class="notes-empty-state"><p>Note not found</p></div>';
2282
2522
  return;
@@ -2306,7 +2546,6 @@ async function selectNote(id) {
2306
2546
  deleteCurrentNote(id);
2307
2547
  });
2308
2548
 
2309
- // Ctrl/Cmd+S to save
2310
2549
  pane.addEventListener('keydown', function(e) {
2311
2550
  if ((e.metaKey || e.ctrlKey) && e.key === 's') {
2312
2551
  e.preventDefault();
@@ -2317,6 +2556,7 @@ async function selectNote(id) {
2317
2556
 
2318
2557
  async function saveCurrentNote() {
2319
2558
  if (!notesState.activeId) return;
2559
+ var plural = getEntityPlural(notesState.entityType);
2320
2560
 
2321
2561
  var titleEl = document.getElementById('notes-title');
2322
2562
  var title = titleEl ? titleEl.value : '';
@@ -2328,7 +2568,7 @@ async function saveCurrentNote() {
2328
2568
  updates.content = content;
2329
2569
  updates.updated = new Date().toISOString().split('T')[0];
2330
2570
 
2331
- var ok = await patchItem('notes', notesState.activeId, updates);
2571
+ var ok = await patchItem(plural, notesState.activeId, updates);
2332
2572
  if (ok) {
2333
2573
  notesState.dirty = false;
2334
2574
  await loadNoteList();
@@ -2336,7 +2576,8 @@ async function saveCurrentNote() {
2336
2576
  }
2337
2577
 
2338
2578
  async function createNewNote() {
2339
- var result = await createItem('notes', { title: 'Untitled', content: '' });
2579
+ var plural = getEntityPlural(notesState.entityType);
2580
+ var result = await createItem(plural, { title: 'Untitled', content: '' });
2340
2581
  if (result && result.id) {
2341
2582
  await loadNoteList();
2342
2583
  selectNote(result.id);
@@ -2345,7 +2586,8 @@ async function createNewNote() {
2345
2586
 
2346
2587
  async function deleteCurrentNote(id) {
2347
2588
  if (!confirm('Delete this note?')) return;
2348
- var ok = await deleteItem('notes', id);
2589
+ var plural = getEntityPlural(notesState.entityType);
2590
+ var ok = await deleteItem(plural, id);
2349
2591
  if (ok) {
2350
2592
  if (notesState.editor) {
2351
2593
  destroyEditor(notesState.editor);
@@ -2366,268 +2608,140 @@ async function deleteCurrentNote(id) {
2366
2608
 
2367
2609
 
2368
2610
  /* ══════════════════════════════════════════════════════════════
2369
- CRUD HELPERS
2611
+ PANEL — Dynamic detail panel from config
2370
2612
  ══════════════════════════════════════════════════════════════ */
2371
- function isSourceWritable() {
2372
- if (!hasWorkspace() || !D.activeSource) return true;
2373
- var src = D.config.workspace.sources.find(function(s) { return s.name === D.activeSource; });
2374
- return src ? !src.readonly : true;
2375
- }
2613
+ var panelState = { open: false, entityType: null, item: null, isCreate: false, editor: null };
2376
2614
 
2377
- function openCreateDialog(collection) {
2378
- var item = { _isNew: true };
2379
-
2380
- if (collection === 'tasks') {
2381
- var ms = D.milestones.length > 0 ? (D.milestones[0].id || D.milestones[0]._dir || '') : '';
2382
- var ep = D.epics.length > 0 ? (D.epics[0]._dir || D.epics[0].id || '') : '';
2383
- item.title = '';
2384
- item.status = 'backlog';
2385
- item.priority = '';
2386
- item.points = null;
2387
- item.assigned = '';
2388
- item.sprint = '';
2389
- item.milestone = ms;
2390
- item.epic = ep;
2391
- item.content = '';
2392
- } else if (collection === 'milestones') {
2393
- item.title = '';
2394
- item.status = 'planned';
2395
- item.deadline = '';
2396
- item.content = '';
2397
- } else if (collection === 'epics') {
2398
- var ms = D.milestones.length > 0 ? (D.milestones[0]._dir || D.milestones[0].id || '') : '';
2399
- item.title = '';
2400
- item.status = 'active';
2401
- item.priority = '';
2402
- item.milestone = ms;
2403
- item.content = '';
2404
- }
2405
-
2406
- panelState = { open: true, type: collection, item: item, isCreate: true, editor: null };
2407
- renderPanel();
2615
+ async function openPanel(entityType, item) {
2616
+ panelState = { open: true, entityType: entityType, item: JSON.parse(JSON.stringify(item)), isCreate: false, editor: null };
2408
2617
  document.getElementById('detail-panel').classList.add('open');
2409
2618
  document.getElementById('panel-overlay').classList.add('open');
2410
- }
2411
2619
 
2412
- /* ══════════════════════════════════════════════════════════════
2413
- DETAIL PANEL Notion-style: form top + editor bottom
2414
- ══════════════════════════════════════════════════════════════ */
2415
- var panelState = { open: false, type: null, item: null, isCreate: false, editor: null };
2620
+ // Fetch full item with content from single endpoint
2621
+ var plural = getEntityPlural(entityType);
2622
+ var base = apiBase();
2623
+ var full = await fetchJson(base + '/' + plural + '/' + encodeURIComponent(item.id));
2624
+ if (full && full.content !== undefined) {
2625
+ panelState.item.content = full.content;
2626
+ }
2416
2627
 
2417
- function openPanel(type, item) {
2418
- panelState = { open: true, type: type, item: JSON.parse(JSON.stringify(item)), isCreate: false, editor: null };
2419
2628
  renderPanel();
2420
- document.getElementById('detail-panel').classList.add('open');
2421
- document.getElementById('panel-overlay').classList.add('open');
2422
2629
  }
2423
2630
 
2424
2631
  function closePanel() {
2425
2632
  if (panelState.editor) { destroyEditor(panelState.editor); panelState.editor = null; }
2426
- panelState = { open: false, type: null, item: null, editor: null };
2633
+ panelState = { open: false, entityType: null, item: null, isCreate: false, editor: null };
2427
2634
  document.getElementById('detail-panel').classList.remove('open');
2428
2635
  document.getElementById('panel-overlay').classList.remove('open');
2429
2636
  }
2430
2637
 
2638
+ function openCreateDialog(entityType) {
2639
+ var fields = getEntityFields(entityType);
2640
+ var item = { _isNew: true, title: '' };
2641
+
2642
+ // Set defaults from field definitions
2643
+ for (var name in fields) {
2644
+ var f = fields[name];
2645
+ if (f.default !== undefined) item[name] = f.default;
2646
+ else if (f.type === 'enum' && f.values && f.values.length > 0) item[name] = f.values[0].key;
2647
+ else if (f.type === 'number') item[name] = null;
2648
+ else if (f.type === 'list') item[name] = '';
2649
+ else item[name] = '';
2650
+ }
2651
+
2652
+ // Set ancestor defaults
2653
+ var ancestors = findAncestors(entityType);
2654
+ for (var i = 0; i < ancestors.length; i++) {
2655
+ var ancType = ancestors[i];
2656
+ var ancItems = getEntities(ancType);
2657
+ if (ancItems.length > 0) {
2658
+ item[ancType] = ancItems[0]._dir || ancItems[0].id || '';
2659
+ }
2660
+ }
2661
+
2662
+ panelState = { open: true, entityType: entityType, item: item, isCreate: true, editor: null };
2663
+ renderPanel();
2664
+ document.getElementById('detail-panel').classList.add('open');
2665
+ document.getElementById('panel-overlay').classList.add('open');
2666
+ }
2667
+
2431
2668
  function renderPanel() {
2432
2669
  var panel = document.getElementById('detail-panel');
2433
- var t = panelState.type;
2670
+ var entityType = panelState.entityType;
2434
2671
  var item = panelState.item;
2435
2672
  var isCreate = panelState.isCreate;
2436
- if (!item) return;
2437
-
2438
- var isReadonly = !isCreate && item.readonly;
2673
+ if (!item || !entityType) return;
2439
2674
 
2440
- var typeLabel;
2441
- if (t === 'tasks') typeLabel = ENTITY_NAMES.task ? ENTITY_NAMES.task.singular : 'Task';
2442
- else if (t === 'epics') typeLabel = ENTITY_NAMES.epic ? ENTITY_NAMES.epic.singular : 'Epic';
2443
- else typeLabel = ENTITY_NAMES.milestone ? ENTITY_NAMES.milestone.singular : 'Milestone';
2675
+ var isReadonly = !isCreate && (item._readonly || !isSourceWritable());
2676
+ var typeLabel = getEntitySingular(entityType);
2677
+ var fields = getEntityFields(entityType);
2678
+ var plural = getEntityPlural(entityType);
2444
2679
 
2445
2680
  // ── Header ──
2446
2681
  var html = '<div class="panel-header">' +
2447
2682
  '<span class="panel-type">' + escHtml(isCreate ? 'New ' + typeLabel : typeLabel) + '</span>' +
2448
2683
  '<span class="panel-item-id">' + escHtml(isCreate ? '' : (item.id || '')) + '</span>';
2449
2684
 
2450
- if (item.source && item.sourceColor) {
2451
- html += '<span class="pill" style="background:' + item.sourceColor + '20;color:' + item.sourceColor + ';font-size:10px">' + escHtml(item.sourceLabel || item.source) + '</span>';
2685
+ if (item._source && item._sourceColor) {
2686
+ html += '<span class="pill" style="background:' + item._sourceColor + '20;color:' + item._sourceColor + ';font-size:10px">' + escHtml(item._sourceLabel || item._source) + '</span>';
2452
2687
  }
2453
2688
 
2454
2689
  html += '<button class="panel-close" id="panel-close-btn">&times;</button></div>';
2455
2690
 
2456
- // ── Title (large, Notion-style) ──
2691
+ // ── Title ──
2457
2692
  html += '<div class="panel-title-wrap">' +
2458
2693
  '<input type="text" id="p-title" class="panel-title-input" value="' + escHtml(item.title || '') + '" placeholder="Untitled"' + (isReadonly ? ' disabled' : '') + '>' +
2459
2694
  '</div>';
2460
2695
 
2461
- // ── Properties (compact inline) ──
2696
+ // ── Properties ──
2462
2697
  html += '<div class="panel-properties">';
2463
-
2464
- // Properties toggle header
2465
- html += '<div class="prop-row prop-row-toggle" id="props-toggle"><span class="prop-label" style="font-weight:600"><span class="ai-toggle-arrow" id="props-arrow">&#9654;</span> Properties</span><div class="prop-value"></div></div>';
2466
- html += '<div class="props-fields-body" id="props-fields-body" style="display:none">';
2467
-
2468
- // Status
2469
- var statusOptions, statusLabels;
2470
- if (t === 'tasks' && D.config && D.config.statuses && D.config.statuses.task) {
2471
- statusOptions = D.config.statuses.task.map(function(s) { return s.key; });
2472
- statusLabels = {};
2473
- D.config.statuses.task.forEach(function(s) { statusLabels[s.key] = s.label; });
2474
- } else if (t === 'epics' && D.config && D.config.statuses && D.config.statuses.epic) {
2475
- statusOptions = D.config.statuses.epic.map(function(s) { return s.key; });
2476
- statusLabels = {};
2477
- D.config.statuses.epic.forEach(function(s) { statusLabels[s.key] = s.label; });
2478
- } else if (t === 'milestones' && D.config && D.config.statuses && D.config.statuses.milestone) {
2479
- statusOptions = D.config.statuses.milestone.map(function(s) { return s.key; });
2480
- statusLabels = {};
2481
- D.config.statuses.milestone.forEach(function(s) { statusLabels[s.key] = s.label; });
2482
- } else if (t === 'tasks') {
2483
- statusOptions = STATUSES;
2484
- statusLabels = STATUS_LABELS;
2485
- } else {
2486
- statusOptions = ['planned', 'active', 'completed', 'cancelled'];
2487
- statusLabels = { planned: 'Planned', active: 'Active', completed: 'Completed', cancelled: 'Cancelled' };
2488
- }
2489
-
2490
- html += '<div class="prop-row"><span class="prop-label">Status</span><div class="prop-value"><span id="p-status-icon">' + statusIcon(item.status) + '</span>' +
2491
- '<select id="p-status"' + (isReadonly ? ' disabled' : '') + '>' + statusOptions.map(function(s) {
2492
- return '<option value="' + s + '"' + (item.status === s ? ' selected' : '') + '>' + (statusLabels[s] || s) + '</option>';
2493
- }).join('') + '</select></div></div>';
2494
-
2495
- if (t === 'tasks' || t === 'epics') {
2496
- html += '<div class="prop-row"><span class="prop-label">Priority</span><div class="prop-value"><span id="p-priority-icon">' + priorityIcon(item.priority) + '</span>' +
2497
- '<select id="p-priority"' + (isReadonly ? ' disabled' : '') + '><option value="">None</option>' + PRIORITIES.map(function(p) {
2498
- return '<option value="' + p + '"' + (item.priority === p ? ' selected' : '') + '>' + (PRIORITY_LABELS[p] || p) + '</option>';
2499
- }).join('') + '</select></div></div>';
2500
- }
2501
-
2502
- if (t === 'tasks') {
2503
- html += '<div class="prop-row"><span class="prop-label">Points</span><div class="prop-value"><input type="number" id="p-points" min="0" max="100" value="' + (item.points != null ? item.points : '') + '" placeholder="0"' + (isReadonly ? ' disabled' : '') + '></div></div>';
2504
-
2505
- var assignedVal = Array.isArray(item.assigned) ? item.assigned.join(', ') : (item.assigned || '');
2506
- html += '<div class="prop-row"><span class="prop-label">Assigned</span><div class="prop-value"><input type="text" id="p-assigned" value="' + escHtml(assignedVal) + '" placeholder="Unassigned"' + (isReadonly ? ' disabled' : '') + '></div></div>';
2507
-
2508
- html += '<div class="prop-row"><span class="prop-label">Sprint</span><div class="prop-value"><select id="p-sprint"' + (isReadonly ? ' disabled' : '') + '><option value="">None</option>' +
2509
- D.allSprints.map(function(s) { return '<option value="' + escHtml(s.id || '') + '"' + (item.sprint === s.id ? ' selected' : '') + '>' + escHtml(s.id || '') + '</option>'; }).join('') +
2510
- '</select></div></div>';
2511
-
2512
- if (isCreate) {
2513
- html += '<div class="prop-row"><span class="prop-label">Milestone</span><div class="prop-value"><select id="p-milestone">' +
2514
- D.milestones.map(function(m) { return '<option value="' + escHtml(m._dir || m.id || '') + '"' + (item.milestone === (m._dir || m.id) ? ' selected' : '') + '>' + escHtml(m.title || m.id || '') + '</option>'; }).join('') +
2515
- '</select></div></div>';
2516
- html += '<div class="prop-row"><span class="prop-label">Epic</span><div class="prop-value"><select id="p-epic-select">' +
2517
- D.epics.map(function(e) { return '<option value="' + escHtml(e._dir || e.id || '') + '"' + (item.epic === (e._dir || e.id) ? ' selected' : '') + '>' + escHtml(e.title || e.id || '') + '</option>'; }).join('') +
2518
- '</select></div></div>';
2519
- } else {
2520
- html += '<div class="prop-row"><span class="prop-label">Epic</span><div class="prop-value"><span class="prop-text">' + escHtml(item.epic || 'None') + '</span></div></div>';
2521
- }
2522
- }
2523
-
2524
- if (t === 'milestones') {
2525
- html += '<div class="prop-row"><span class="prop-label">Deadline</span><div class="prop-value"><input type="date" id="p-deadline" value="' + fmtDate(item.deadline) + '"' + (isReadonly ? ' disabled' : '') + '></div></div>';
2526
- }
2527
-
2528
- if (isCreate && (t === 'epics' || t === 'milestones')) {
2529
- if (t === 'epics') {
2530
- html += '<div class="prop-row"><span class="prop-label">Milestone</span><div class="prop-value"><select id="p-milestone">' +
2531
- D.milestones.map(function(m) { return '<option value="' + escHtml(m._dir || m.id || '') + '">' + escHtml(m.title || m.id || '') + '</option>'; }).join('') +
2532
- '</select></div></div>';
2533
- }
2534
- }
2535
-
2536
- // Links
2537
- if (t === 'tasks' && !isCreate && item.links && Array.isArray(item.links) && item.links.length > 0) {
2538
- html += '<div class="prop-row"><span class="prop-label">Links</span><div class="prop-value"><div class="link-chips">';
2539
- item.links.forEach(function(link) {
2540
- var parts = String(link).split(':');
2541
- var srcName = parts.length === 2 ? parts[0] : null;
2542
- var srcColor = 'var(--accent)';
2543
- if (srcName && D.config && D.config.workspace && D.config.workspace.sources) {
2544
- var src = D.config.workspace.sources.find(function(s) { return s.name === srcName; });
2545
- if (src && src.color) srcColor = src.color;
2698
+ html += '<div class="prop-row prop-row-toggle" id="props-toggle"><span class="prop-label" style="font-weight:600"><span class="ai-toggle-arrow" id="props-arrow">&#9660;</span> Properties</span><div class="prop-value"></div></div>';
2699
+ html += '<div class="props-fields-body" id="props-fields-body">';
2700
+
2701
+ // Generate property rows from field definitions
2702
+ for (var name in fields) {
2703
+ var field = fields[name];
2704
+ var value = item[name];
2705
+ html += renderPropRow(name, field, value, entityType, isReadonly);
2706
+ }
2707
+
2708
+ // Ancestor fields for create
2709
+ if (isCreate) {
2710
+ var ancestors = findAncestors(entityType);
2711
+ for (var ai = 0; ai < ancestors.length; ai++) {
2712
+ var ancType = ancestors[ai];
2713
+ var ancDef = getEntityDef(ancType);
2714
+ var ancItems = getEntities(ancType);
2715
+ var ancLabel = ancDef ? ancDef.singular : ancType;
2716
+ var ancVal = item[ancType] || '';
2717
+
2718
+ html += '<div class="prop-row"><span class="prop-label">' + escHtml(ancLabel) + '</span><div class="prop-value">';
2719
+ html += '<select id="p-ancestor-' + ancType + '">';
2720
+ for (var aci = 0; aci < ancItems.length; aci++) {
2721
+ var a = ancItems[aci];
2722
+ var aId = a._dir || a.id || '';
2723
+ html += '<option value="' + escHtml(aId) + '"' + (ancVal === aId ? ' selected' : '') + '>' + escHtml(a.title || a.id || '') + '</option>';
2546
2724
  }
2547
- html += '<span class="link-chip" data-link="' + escHtml(String(link)) + '">' +
2548
- '<span class="link-chip-dot" style="background:' + srcColor + '"></span>' +
2549
- escHtml(String(link)) + '</span>';
2550
- });
2551
- html += '</div></div></div>';
2552
- }
2553
-
2554
- // Reverse links
2555
- if (t === 'tasks' && !isCreate && D.overviewLinks && D.overviewLinks.reverseLinks && item.id) {
2556
- var reverseRefs = D.overviewLinks.reverseLinks[item.id] || [];
2557
- if (reverseRefs.length > 0) {
2558
- html += '<div class="prop-row"><span class="prop-label">Referenced By</span><div class="prop-value"><div class="link-chips">';
2559
- reverseRefs.forEach(function(ref) {
2560
- var srcColor = ref.sourceColor || 'var(--accent)';
2561
- html += '<span class="link-chip" data-link="' + escHtml(ref.from || '') + '">' +
2562
- '<span class="link-chip-dot" style="background:' + srcColor + '"></span>' +
2563
- escHtml(ref.from || '') + '</span>';
2564
- });
2565
- html += '</div></div></div>';
2725
+ html += '</select></div></div>';
2566
2726
  }
2567
- }
2568
-
2569
- html += '</div>'; // close props-fields-body
2570
-
2571
- // ── AI Properties — integrated as prop-rows ──
2572
- var aiData = item.ai || {};
2573
- var aiOwnData = item.aiOwn || {};
2574
- var aiFieldDefs = [
2575
- { key: 'skills', label: 'Skills', icon: '<svg class="icon icon-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>' },
2576
- { key: 'agents', label: 'Agents', icon: '<svg class="icon icon-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="3"/><circle cx="9" cy="16" r="1" fill="currentColor"/><circle cx="15" cy="16" r="1" fill="currentColor"/></svg>' },
2577
- { key: 'mcps', label: 'MCPs', icon: '<svg class="icon icon-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16v16H4z"/><path d="M9 9h6v6H9z"/><path d="M9 1v3M15 1v3M9 20v3M15 20v3M1 9h3M1 15h3M20 9h3M20 15h3"/></svg>' },
2578
- { key: 'commands', label: 'Commands', icon: '<svg class="icon icon-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>' },
2579
- { key: 'context', label: 'Context', icon: '<svg class="icon icon-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>' }
2580
- ];
2581
- var aiTotalCount = 0;
2582
- for (var ai = 0; ai < aiFieldDefs.length; ai++) {
2583
- var arr = aiData[aiFieldDefs[ai].key];
2584
- if (arr) aiTotalCount += arr.length;
2585
- }
2586
- var showAiSection = aiTotalCount > 0 || !isReadonly;
2587
- if (showAiSection) {
2588
- // AI divider row — clickable toggle
2589
- html += '<div class="prop-row prop-row-divider prop-row-toggle" id="ai-toggle"><span class="prop-label" style="font-weight:600"><span class="ai-toggle-arrow" id="ai-arrow">&#9654;</span> AI</span><div class="prop-value">';
2590
- if (aiTotalCount > 0) html += '<span class="pill" style="font-size:10px;padding:1px 6px">' + aiTotalCount + '</span>';
2591
- html += '</div></div>';
2592
- html += '<div class="ai-fields-body" id="ai-fields-body" style="display:none">';
2593
- for (var ag = 0; ag < aiFieldDefs.length; ag++) {
2594
- var def = aiFieldDefs[ag];
2595
- var resolved = aiData[def.key] || [];
2596
- var own = aiOwnData[def.key] || [];
2597
- var inherited = resolved.filter(function(v) { return own.indexOf(v) === -1; });
2598
- if (isReadonly && resolved.length === 0) continue;
2599
- html += '<div class="prop-row"><span class="prop-label">' + def.icon + ' ' + escHtml(def.label) + '</span><div class="prop-value">';
2600
- if (isReadonly) {
2601
- // Readonly: just show tags
2602
- for (var ci = 0; ci < resolved.length; ci++) {
2603
- var isOwn = own.indexOf(resolved[ci]) !== -1;
2604
- html += '<span class="ai-tag' + (isOwn ? ' ai-tag-own' : ' ai-tag-inherited') + '">' + escHtml(resolved[ci]) + '</span>';
2605
- }
2606
- } else {
2607
- // Editable: tag input container
2608
- html += '<div class="ai-tag-input" data-ai-field="' + def.key + '">';
2609
- // Inherited tags (dashed, no remove)
2610
- for (var ii = 0; ii < inherited.length; ii++) {
2611
- html += '<span class="ai-tag ai-tag-inherited">' + escHtml(inherited[ii]) + '</span>';
2612
- }
2613
- // Own tags (accent, with remove button)
2614
- for (var oi = 0; oi < own.length; oi++) {
2615
- html += '<span class="ai-tag ai-tag-own"><span class="ai-tag-val">' + escHtml(own[oi]) + '</span><button class="ai-tag-remove" type="button">&times;</button></span>';
2616
- }
2617
- // Text input for adding
2618
- html += '<input type="text" class="ai-tag-text" placeholder="Add...">';
2619
- // Autocomplete dropdown
2620
- html += '<div class="ai-autocomplete"></div>';
2621
- html += '</div>';
2727
+ } else {
2728
+ // Show ancestor refs as readonly
2729
+ var ancestors = findAncestors(entityType);
2730
+ for (var ai = 0; ai < ancestors.length; ai++) {
2731
+ var ancType = ancestors[ai];
2732
+ var ancVal = item['_' + ancType];
2733
+ if (ancVal) {
2734
+ var ancDef = getEntityDef(ancType);
2735
+ var ancLabel = ancDef ? ancDef.singular : ancType;
2736
+ html += '<div class="prop-row"><span class="prop-label">' + escHtml(ancLabel) + '</span><div class="prop-value"><span class="prop-text">' + escHtml(ancVal) + '</span></div></div>';
2622
2737
  }
2623
- html += '</div></div>';
2624
2738
  }
2625
- html += '</div>'; // close ai-fields-body
2626
2739
  }
2627
2740
 
2741
+ html += '</div>'; // close props-fields-body
2628
2742
  html += '</div>'; // close panel-properties
2629
2743
 
2630
- // ── Content Editor (fills remaining space) ──
2744
+ // ── Content Editor ──
2631
2745
  if (isReadonly) {
2632
2746
  html += '<div class="panel-content-area"><div id="p-content-editor" class="panel-editor-zone md-rendered">' + simpleMarkdownRender(item.content || '') + '</div></div>';
2633
2747
  } else {
@@ -2636,7 +2750,7 @@ function renderPanel() {
2636
2750
 
2637
2751
  // ── Footer ──
2638
2752
  html += '<div class="panel-footer">';
2639
- if (!isCreate && !isReadonly && isSourceWritable()) {
2753
+ if (!isCreate && !isReadonly) {
2640
2754
  html += '<button class="btn btn-danger btn-sm" id="panel-archive-btn">Archive</button>';
2641
2755
  }
2642
2756
  html += '<span style="flex:1"></span>';
@@ -2648,7 +2762,7 @@ function renderPanel() {
2648
2762
 
2649
2763
  panel.innerHTML = html;
2650
2764
 
2651
- // Initialize Editor.js for content (if not readonly)
2765
+ // Initialize Editor.js
2652
2766
  if (!isReadonly) {
2653
2767
  panelState.editor = initEditor('p-content-editor', item.content || '', {
2654
2768
  placeholder: 'Type / for commands, or start writing...',
@@ -2666,13 +2780,13 @@ function renderPanel() {
2666
2780
  if (archiveBtn) {
2667
2781
  archiveBtn.addEventListener('click', function() {
2668
2782
  if (!confirm('Archive this ' + typeLabel.toLowerCase() + '?')) return;
2669
- deleteItem(t, item.id).then(function(ok) {
2783
+ deleteItem(plural, item.id).then(function(ok) {
2670
2784
  if (ok) { closePanel(); refreshData(); }
2671
2785
  });
2672
2786
  });
2673
2787
  }
2674
2788
 
2675
- // Properties section toggle
2789
+ // Properties toggle
2676
2790
  var propsToggle = document.getElementById('props-toggle');
2677
2791
  if (propsToggle) {
2678
2792
  propsToggle.addEventListener('click', function() {
@@ -2688,139 +2802,50 @@ function renderPanel() {
2688
2802
  });
2689
2803
  }
2690
2804
 
2691
- // AI section toggle
2692
- var aiToggle = document.getElementById('ai-toggle');
2693
- if (aiToggle) {
2694
- aiToggle.addEventListener('click', function() {
2695
- var body = document.getElementById('ai-fields-body');
2696
- var arrow = document.getElementById('ai-arrow');
2697
- if (body.style.display === 'none') {
2698
- body.style.display = '';
2699
- arrow.innerHTML = '&#9660;';
2700
- } else {
2701
- body.style.display = 'none';
2702
- arrow.innerHTML = '&#9654;';
2805
+ // Longtext expand/collapse
2806
+ panel.querySelectorAll('.prop-longtext-preview').forEach(function(prev) {
2807
+ prev.addEventListener('click', function() {
2808
+ var wrap = prev.parentNode;
2809
+ var textarea = wrap.querySelector('.prop-longtext-editor');
2810
+ var toggle = prev.querySelector('.prop-longtext-toggle');
2811
+ if (textarea.style.display === 'none') {
2812
+ textarea.style.display = '';
2813
+ prev.style.display = 'none';
2814
+ textarea.style.height = 'auto';
2815
+ textarea.style.height = Math.max(80, textarea.scrollHeight) + 'px';
2816
+ textarea.focus();
2703
2817
  }
2704
2818
  });
2705
- }
2706
-
2707
- // AI tag input event listeners
2708
- panel.querySelectorAll('.ai-tag-input').forEach(function(container) {
2709
- var field = container.dataset.aiField;
2710
- var textInput = container.querySelector('.ai-tag-text');
2711
- var dropdown = container.querySelector('.ai-autocomplete');
2712
- if (!textInput || !dropdown) return;
2713
-
2714
- function getOwnValues() {
2715
- var vals = [];
2716
- container.querySelectorAll('.ai-tag-own .ai-tag-val').forEach(function(el) {
2717
- vals.push(el.textContent);
2718
- });
2719
- return vals;
2720
- }
2721
-
2722
- function getAllValues() {
2723
- var vals = [];
2724
- container.querySelectorAll('.ai-tag').forEach(function(el) {
2725
- var valEl = el.querySelector('.ai-tag-val');
2726
- vals.push(valEl ? valEl.textContent : el.textContent);
2727
- });
2728
- return vals;
2729
- }
2730
-
2731
- function addTag(value) {
2732
- var v = value.trim();
2733
- if (!v) return;
2734
- var existing = getAllValues();
2735
- if (existing.indexOf(v) !== -1) return;
2736
- var tag = document.createElement('span');
2737
- tag.className = 'ai-tag ai-tag-own';
2738
- tag.innerHTML = '<span class="ai-tag-val">' + escHtml(v) + '</span><button class="ai-tag-remove" type="button">&times;</button>';
2739
- tag.querySelector('.ai-tag-remove').addEventListener('click', function() { tag.remove(); });
2740
- container.insertBefore(tag, textInput);
2741
- textInput.value = '';
2742
- closeDropdown();
2743
- }
2744
-
2745
- function closeDropdown() {
2746
- dropdown.classList.remove('open');
2747
- dropdown.innerHTML = '';
2748
- }
2749
-
2750
- function showDropdown() {
2751
- var suggestions = D.aiSuggestions ? (D.aiSuggestions[field] || []) : [];
2752
- var present = getAllValues();
2753
- var query = textInput.value.trim().toLowerCase();
2754
- var filtered = suggestions.filter(function(s) {
2755
- return present.indexOf(s) === -1 && (!query || s.toLowerCase().indexOf(query) !== -1);
2756
- });
2757
- if (filtered.length === 0) { closeDropdown(); return; }
2758
- dropdown.innerHTML = '';
2759
- filtered.forEach(function(s) {
2760
- var item = document.createElement('div');
2761
- item.className = 'ai-autocomplete-item';
2762
- item.textContent = s;
2763
- item.addEventListener('mousedown', function(e) {
2764
- e.preventDefault();
2765
- addTag(s);
2766
- });
2767
- dropdown.appendChild(item);
2768
- });
2769
- dropdown.classList.add('open');
2770
- }
2771
-
2772
- // Click on × to remove own tag
2773
- container.querySelectorAll('.ai-tag-remove').forEach(function(btn) {
2774
- btn.addEventListener('click', function() { btn.closest('.ai-tag').remove(); });
2819
+ });
2820
+ panel.querySelectorAll('.prop-longtext-editor').forEach(function(ta) {
2821
+ ta.addEventListener('blur', function() {
2822
+ var wrap = ta.parentNode;
2823
+ var prev = wrap.querySelector('.prop-longtext-preview');
2824
+ var textSpan = prev.querySelector('.prop-longtext-text');
2825
+ var val = ta.value || '';
2826
+ var truncated = val.length > 80 ? val.substring(0, 80) + '...' : (val || 'Empty');
2827
+ textSpan.textContent = truncated;
2828
+ ta.style.display = 'none';
2829
+ prev.style.display = '';
2775
2830
  });
2776
-
2777
- // Click on container focuses input
2778
- container.addEventListener('click', function(e) {
2779
- if (e.target === container) textInput.focus();
2831
+ ta.addEventListener('input', function() {
2832
+ ta.style.height = 'auto';
2833
+ ta.style.height = Math.max(80, ta.scrollHeight) + 'px';
2780
2834
  });
2835
+ });
2781
2836
 
2782
- // Input typing show autocomplete
2783
- textInput.addEventListener('input', showDropdown);
2784
- textInput.addEventListener('focus', showDropdown);
2785
-
2786
- // Enter → add tag
2787
- textInput.addEventListener('keydown', function(e) {
2788
- if (e.key === 'Enter') {
2789
- e.preventDefault();
2790
- if (textInput.value.trim()) addTag(textInput.value);
2791
- } else if (e.key === 'Backspace' && textInput.value === '') {
2792
- // Remove last own tag
2793
- var ownTags = container.querySelectorAll('.ai-tag-own');
2794
- if (ownTags.length > 0) ownTags[ownTags.length - 1].remove();
2795
- } else if (e.key === 'Escape') {
2796
- closeDropdown();
2797
- textInput.blur();
2837
+ // Live icon updates for enum fields
2838
+ panel.querySelectorAll('select[id^="p-"]').forEach(function(sel) {
2839
+ sel.addEventListener('change', function() {
2840
+ var iconEl = sel.parentNode.querySelector('.prop-icon');
2841
+ if (iconEl) {
2842
+ var fieldName = sel.id.replace('p-', '');
2843
+ iconEl.innerHTML = statusIcon(entityType, sel.value);
2798
2844
  }
2799
2845
  });
2800
-
2801
- // Close dropdown on blur
2802
- textInput.addEventListener('blur', function() {
2803
- setTimeout(closeDropdown, 150);
2804
- });
2805
2846
  });
2806
2847
 
2807
- // Live icon updates
2808
- var statusSel = document.getElementById('p-status');
2809
- if (statusSel) {
2810
- statusSel.addEventListener('change', function() {
2811
- var iconEl = document.getElementById('p-status-icon');
2812
- if (iconEl) iconEl.innerHTML = statusIcon(statusSel.value);
2813
- });
2814
- }
2815
- var prioritySel = document.getElementById('p-priority');
2816
- if (prioritySel) {
2817
- prioritySel.addEventListener('change', function() {
2818
- var iconEl = document.getElementById('p-priority-icon');
2819
- if (iconEl) iconEl.innerHTML = priorityIcon(prioritySel.value);
2820
- });
2821
- }
2822
-
2823
- // Ctrl/Cmd+S to save
2848
+ // Ctrl/Cmd+S
2824
2849
  panel.addEventListener('keydown', function(e) {
2825
2850
  if ((e.metaKey || e.ctrlKey) && e.key === 's') {
2826
2851
  e.preventDefault();
@@ -2828,180 +2853,496 @@ function renderPanel() {
2828
2853
  else savePanel();
2829
2854
  }
2830
2855
  });
2856
+ }
2831
2857
 
2832
- // Link chip click handlers
2833
- panel.querySelectorAll('.link-chip[data-link]').forEach(function(chip) {
2834
- chip.addEventListener('click', function() {
2835
- var ref = chip.dataset.link;
2836
- var parts = ref.split(':');
2837
- if (parts.length === 2 && hasWorkspace()) {
2838
- closePanel();
2839
- switchSource(parts[0]);
2840
- setTimeout(function() {
2841
- var task = D.tasks.find(function(t) { return t.id === ref || t.id === parts[1]; });
2842
- if (task) openPanel('tasks', task);
2843
- }, 500);
2858
+ function renderPropRow(name, field, value, entityType, isReadonly) {
2859
+ var label = field.label || name.charAt(0).toUpperCase() + name.slice(1);
2860
+ var html = '<div class="prop-row"><span class="prop-label">' + escHtml(label) + '</span><div class="prop-value">';
2861
+
2862
+ switch (field.type) {
2863
+ case 'enum':
2864
+ if (field.values) {
2865
+ html += '<span class="prop-icon">' + statusIcon(entityType, value) + '</span>';
2866
+ html += '<select id="p-' + name + '"' + (isReadonly ? ' disabled' : '') + '>';
2867
+ for (var i = 0; i < field.values.length; i++) {
2868
+ var v = field.values[i];
2869
+ html += '<option value="' + escHtml(v.key) + '"' + (value === v.key ? ' selected' : '') + '>' + escHtml(v.label) + '</option>';
2870
+ }
2871
+ html += '</select>';
2844
2872
  }
2845
- });
2846
- });
2873
+ break;
2874
+
2875
+ case 'number':
2876
+ html += '<input type="number" id="p-' + name + '" value="' + (value != null ? value : '') + '" placeholder="0"' + (isReadonly ? ' disabled' : '') + '>';
2877
+ break;
2878
+
2879
+ case 'date':
2880
+ html += '<input type="date" id="p-' + name + '" value="' + fmtDate(value) + '"' + (isReadonly ? ' disabled' : '') + '>';
2881
+ break;
2882
+
2883
+ case 'string':
2884
+ html += '<input type="text" id="p-' + name + '" value="' + escHtml(value || '') + '" placeholder=""' + (isReadonly ? ' disabled' : '') + '>';
2885
+ break;
2886
+
2887
+ case 'text':
2888
+ html += '<input type="text" id="p-' + name + '" value="' + escHtml(value || '') + '" placeholder=""' + (isReadonly ? ' disabled' : '') + '>';
2889
+ break;
2890
+
2891
+ case 'longtext':
2892
+ var ltVal = value || '';
2893
+ var ltTruncated = ltVal.length > 80 ? ltVal.substring(0, 80) + '...' : ltVal;
2894
+ html += '<div class="prop-longtext" id="p-lt-wrap-' + name + '">';
2895
+ html += '<div class="prop-longtext-preview" id="p-lt-preview-' + name + '" title="Click to expand">';
2896
+ html += '<span class="prop-longtext-text">' + escHtml(ltTruncated || 'Empty') + '</span>';
2897
+ html += '<span class="prop-longtext-toggle">&#9660;</span>';
2898
+ html += '</div>';
2899
+ html += '<textarea id="p-' + name + '" class="prop-longtext-editor" style="display:none" placeholder="Enter text..."' + (isReadonly ? ' disabled' : '') + '>' + escHtml(ltVal) + '</textarea>';
2900
+ html += '</div>';
2901
+ break;
2902
+
2903
+ case 'list':
2904
+ var listVal = Array.isArray(value) ? value.join(', ') : (value || '');
2905
+ html += '<input type="text" id="p-' + name + '" value="' + escHtml(listVal) + '" placeholder="Comma-separated"' + (isReadonly ? ' disabled' : '') + '>';
2906
+ break;
2907
+
2908
+ case 'ref':
2909
+ if (isReadonly || !field.entity) {
2910
+ var refVal = Array.isArray(value) ? value.join(', ') : (value || '');
2911
+ html += '<span class="prop-text">' + escHtml(refVal || 'None') + '</span>';
2912
+ } else {
2913
+ html += '<input type="text" id="p-' + name + '" value="' + escHtml(Array.isArray(value) ? value.join(', ') : (value || '')) + '" placeholder="IDs"' + (isReadonly ? ' disabled' : '') + '>';
2914
+ }
2915
+ break;
2916
+
2917
+ default:
2918
+ html += '<span class="prop-text">' + escHtml(value || '') + '</span>';
2919
+ }
2920
+
2921
+ html += '</div></div>';
2922
+ return html;
2847
2923
  }
2848
2924
 
2925
+ /* ── Save (Update) ───────────────────────────────────────── */
2849
2926
  async function savePanel() {
2850
- var t = panelState.type;
2927
+ var entityType = panelState.entityType;
2851
2928
  var item = panelState.item;
2852
- if (!t || !item) return;
2929
+ if (!entityType || !item) return;
2853
2930
 
2931
+ var fields = getEntityFields(entityType);
2932
+ var plural = getEntityPlural(entityType);
2854
2933
  var updates = {};
2855
2934
 
2935
+ // Title
2856
2936
  var titleEl = document.getElementById('p-title');
2857
2937
  if (titleEl && titleEl.value !== (item.title || '')) updates.title = titleEl.value;
2858
2938
 
2859
- var statusEl = document.getElementById('p-status');
2860
- if (statusEl && statusEl.value !== (item.status || '')) updates.status = statusEl.value;
2939
+ // Fields
2940
+ for (var name in fields) {
2941
+ var el = document.getElementById('p-' + name);
2942
+ if (!el) continue;
2943
+ var newVal = readFieldValue(el, fields[name]);
2944
+ var oldVal = item[name];
2945
+
2946
+ if (fields[name].type === 'number') {
2947
+ if (newVal !== oldVal) updates[name] = newVal;
2948
+ } else if (fields[name].type === 'list') {
2949
+ var oldList = Array.isArray(oldVal) ? oldVal.join(', ') : (oldVal || '');
2950
+ if (el.value.trim() !== oldList) updates[name] = newVal;
2951
+ } else if (fields[name].type === 'date') {
2952
+ if (el.value !== fmtDate(oldVal)) updates[name] = newVal;
2953
+ } else {
2954
+ if (newVal !== (oldVal || '')) updates[name] = newVal;
2955
+ }
2956
+ }
2861
2957
 
2862
- if (t === 'tasks' || t === 'epics') {
2863
- var priorityEl = document.getElementById('p-priority');
2864
- if (priorityEl && priorityEl.value !== (item.priority || '')) updates.priority = priorityEl.value || null;
2958
+ // Content
2959
+ if (panelState.editor) {
2960
+ var newContent = await getEditorMarkdown(panelState.editor);
2961
+ if (newContent !== (item.content || '')) updates.content = newContent;
2865
2962
  }
2866
2963
 
2867
- if (t === 'tasks') {
2868
- var pointsEl = document.getElementById('p-points');
2869
- if (pointsEl) {
2870
- var pv = pointsEl.value ? Number(pointsEl.value) : null;
2871
- if (pv !== item.points) updates.points = pv;
2872
- }
2964
+ if (Object.keys(updates).length === 0) {
2965
+ showToast('No changes to save', 'success');
2966
+ closePanel();
2967
+ return;
2968
+ }
2969
+
2970
+ var ok = await patchItem(plural, item.id, updates);
2971
+ if (ok) { closePanel(); refreshData(); }
2972
+ }
2973
+
2974
+ /* ── Save (Create) ───────────────────────────────────────── */
2975
+ async function saveCreatePanel() {
2976
+ var entityType = panelState.entityType;
2977
+ if (!entityType) return;
2978
+
2979
+ var fields = getEntityFields(entityType);
2980
+ var plural = getEntityPlural(entityType);
2981
+ var data = {};
2982
+
2983
+ // Title
2984
+ var titleEl = document.getElementById('p-title');
2985
+ if (titleEl) data.title = titleEl.value || 'Untitled';
2986
+
2987
+ // Fields
2988
+ for (var name in fields) {
2989
+ var el = document.getElementById('p-' + name);
2990
+ if (!el) continue;
2991
+ var val = readFieldValue(el, fields[name]);
2992
+ if (val != null && val !== '') data[name] = val;
2993
+ }
2994
+
2995
+ // Ancestors
2996
+ var ancestors = findAncestors(entityType);
2997
+ for (var i = 0; i < ancestors.length; i++) {
2998
+ var ancEl = document.getElementById('p-ancestor-' + ancestors[i]);
2999
+ if (ancEl && ancEl.value) data[ancestors[i]] = ancEl.value;
3000
+ }
3001
+
3002
+ // Content
3003
+ if (panelState.editor) {
3004
+ var content = await getEditorMarkdown(panelState.editor);
3005
+ if (content) data.content = content;
3006
+ }
3007
+
3008
+ var result = await createItem(plural, data);
3009
+ if (result) { closePanel(); refreshData(); }
3010
+ }
3011
+
3012
+ function readFieldValue(el, fieldDef) {
3013
+ switch (fieldDef.type) {
3014
+ case 'number':
3015
+ return el.value ? Number(el.value) : null;
3016
+ case 'list':
3017
+ return el.value.trim() || null;
3018
+ case 'date':
3019
+ return el.value || null;
3020
+ case 'longtext':
3021
+ return el.value || '';
3022
+ case 'enum':
3023
+ case 'string':
3024
+ case 'text':
3025
+ default:
3026
+ return el.value;
3027
+ }
3028
+ }
3029
+
3030
+ // Close panel on overlay click or Escape
3031
+ document.getElementById('panel-overlay').addEventListener('click', closePanel);
3032
+ document.addEventListener('keydown', function(e) {
3033
+ if (e.key === 'Escape' && panelState.open) closePanel();
3034
+ });
3035
+
3036
+
3037
+ /* ══════════════════════════════════════════════════════════════
3038
+ WORKSPACE — Source switching & sidebar
3039
+ ══════════════════════════════════════════════════════════════ */
3040
+ function renderSidebarLogo() {
3041
+ var p = D.project || {};
3042
+ var logoEl = document.getElementById('sidebar-logo');
3043
+ var iconEl = document.getElementById('sidebar-logo-icon');
3044
+ var textEl = document.getElementById('sidebar-logo-text');
3045
+ if (!iconEl || !textEl) return;
3046
+
3047
+ textEl.textContent = p.name || 'Project';
3048
+
3049
+ if (D.config && D.config.logo) {
3050
+ var logoSrc = D.config.logo.startsWith('http') ? D.config.logo : '/logo';
3051
+ iconEl.innerHTML = '<img src="' + escHtml(logoSrc) + '" alt="">';
3052
+ } else {
3053
+ var initial = (p.name || 'P').charAt(0).toUpperCase();
3054
+ iconEl.textContent = initial;
3055
+ }
3056
+
3057
+ logoEl.onclick = function() {};
3058
+ }
3059
+
3060
+ function buildSourceRail() {
3061
+ var rail = document.getElementById('source-rail');
3062
+ if (!rail) return;
3063
+ rail.innerHTML = '';
3064
+
3065
+ if (hasWorkspace()) {
3066
+ var wsSources = D.config.workspace.sources;
3067
+ for (var i = 0; i < wsSources.length; i++) {
3068
+ var s = wsSources[i];
3069
+ var color = s.color || 'var(--accent)';
3070
+ var icon = document.createElement('div');
3071
+ icon.className = 'rail-icon' + (D.activeSource === s.name ? ' active' : '');
3072
+ icon.dataset.source = s.name;
3073
+ icon.setAttribute('data-tooltip', s.label || s.name);
3074
+ icon.style.background = color + '20';
3075
+ icon.style.color = color;
3076
+ icon.innerHTML = escHtml(s.icon || s.name.charAt(0).toUpperCase());
3077
+ icon.onclick = (function(name) {
3078
+ return function() { switchSource(name); };
3079
+ })(s.name);
3080
+ rail.appendChild(icon);
3081
+ }
3082
+ }
3083
+
3084
+ // Add source button — always visible
3085
+ var addBtn = document.createElement('div');
3086
+ addBtn.className = 'rail-icon rail-add';
3087
+ addBtn.setAttribute('data-tooltip', 'Add source');
3088
+ addBtn.textContent = '+';
3089
+ addBtn.onclick = function() { openAddSourceModal(); };
3090
+ rail.appendChild(addBtn);
3091
+
3092
+ // Switch project icon — always visible
3093
+ var switchDivider = document.createElement('div');
3094
+ switchDivider.className = 'rail-divider';
3095
+ switchDivider.style.marginTop = 'auto';
3096
+ rail.appendChild(switchDivider);
3097
+
3098
+ var settingsIcon = document.createElement('div');
3099
+ settingsIcon.className = 'rail-icon rail-switch';
3100
+ settingsIcon.setAttribute('data-tooltip', 'Settings');
3101
+ settingsIcon.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>';
3102
+ settingsIcon.onclick = function() { openSettings(); };
3103
+ rail.appendChild(settingsIcon);
3104
+
3105
+ var switchIcon = document.createElement('div');
3106
+ switchIcon.className = 'rail-icon rail-switch';
3107
+ switchIcon.setAttribute('data-tooltip', 'Switch workspace');
3108
+ switchIcon.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 3h5v5"/><path d="M21 3l-7 7"/><path d="M8 21H3v-5"/><path d="M3 21l7-7"/></svg>';
3109
+ switchIcon.onclick = function() { openHistoryModal(); };
3110
+ rail.appendChild(switchIcon);
3111
+ }
2873
3112
 
2874
- var assignedEl = document.getElementById('p-assigned');
2875
- if (assignedEl) {
2876
- var av = assignedEl.value.trim();
2877
- var origAssigned = Array.isArray(item.assigned) ? item.assigned.join(', ') : (item.assigned || '');
2878
- if (av !== origAssigned) updates.assigned = av || null;
2879
- }
3113
+ function switchSource(sourceName) {
3114
+ if (sourceName === D.activeSource) return;
2880
3115
 
2881
- var sprintEl = document.getElementById('p-sprint');
2882
- if (sprintEl && sprintEl.value !== (item.sprint || '')) updates.sprint = sprintEl.value || null;
2883
- }
3116
+ D.activeSource = sourceName;
3117
+ D.loaded = false;
2884
3118
 
2885
- if (t === 'milestones') {
2886
- var deadlineEl = document.getElementById('p-deadline');
2887
- if (deadlineEl && deadlineEl.value !== fmtDate(item.deadline)) updates.deadline = deadlineEl.value || null;
2888
- }
3119
+ try { localStorage.setItem('mdboard-source', sourceName); } catch(e) {}
2889
3120
 
2890
- if (panelState.editor) {
2891
- var newContent = await getEditorMarkdown(panelState.editor);
2892
- if (newContent !== (item.content || '')) updates.content = newContent;
2893
- }
3121
+ document.querySelectorAll('.rail-icon').forEach(function(el) {
3122
+ el.classList.toggle('active', el.dataset.source === sourceName);
3123
+ });
2894
3124
 
2895
- // AI properties — read from tag inputs
2896
- var aiTagInputs = document.querySelectorAll('.ai-tag-input[data-ai-field]');
2897
- if (aiTagInputs.length > 0) {
2898
- var aiObj = {};
2899
- aiTagInputs.forEach(function(container) {
2900
- var field = container.dataset.aiField;
2901
- var vals = [];
2902
- container.querySelectorAll('.ai-tag-own .ai-tag-val').forEach(function(el) {
2903
- var v = el.textContent.trim();
2904
- if (v) vals.push(v);
2905
- });
2906
- if (vals.length > 0) aiObj[field] = vals;
2907
- });
2908
- if (Object.keys(aiObj).length > 0) {
2909
- updates.ai = aiObj;
2910
- } else if (item.aiOwn && Object.keys(item.aiOwn).length > 0) {
2911
- updates.ai = null;
2912
- }
2913
- }
3125
+ loadAll().then(function() {
3126
+ generateDynamicStyles();
3127
+ renderCurrentView();
3128
+ buildSourceRail();
3129
+ });
3130
+ }
2914
3131
 
2915
- if (Object.keys(updates).length === 0) {
2916
- showToast('No changes to save', 'success');
2917
- closePanel();
2918
- return;
2919
- }
3132
+ /* ── Add Source Modal ────────────────────────────────────── */
3133
+ var _addSourceState = { type: 'local', color: '#5B6EF5' };
3134
+ var _addSourceColors = ['#F97316','#8B5CF6','#EC4899','#10B981','#06B6D4','#5B6EF5','#6366F1','#D4A72C'];
2920
3135
 
2921
- var ok = await patchItem(t, item.id, updates);
2922
- if (ok) {
2923
- closePanel();
2924
- refreshData();
2925
- }
3136
+ function openAddSourceModal() {
3137
+ _addSourceState = { type: 'local', color: '#5B6EF5' };
3138
+ renderAddSourceModal();
3139
+ var overlay = document.getElementById('addsource-overlay');
3140
+ var modal = document.getElementById('addsource-modal');
3141
+ if (overlay) { overlay.classList.add('open'); overlay.onclick = closeAddSourceModal; }
3142
+ if (modal) modal.classList.add('open');
2926
3143
  }
2927
3144
 
2928
- async function saveCreatePanel() {
2929
- var t = panelState.type;
2930
- if (!t) return;
2931
-
2932
- var data = {};
3145
+ function closeAddSourceModal() {
3146
+ var overlay = document.getElementById('addsource-overlay');
3147
+ var modal = document.getElementById('addsource-modal');
3148
+ if (overlay) overlay.classList.remove('open');
3149
+ if (modal) modal.classList.remove('open');
3150
+ }
2933
3151
 
2934
- var titleEl = document.getElementById('p-title');
2935
- if (titleEl) data.title = titleEl.value || 'Untitled';
3152
+ function renderAddSourceModal() {
3153
+ var modal = document.getElementById('addsource-modal');
3154
+ if (!modal) return;
3155
+ var st = _addSourceState;
3156
+ var isLocal = st.type === 'local';
3157
+
3158
+ var html = '<div class="addsource-header"><h2>Add Source</h2>'
3159
+ + '<button class="panel-close" id="addsource-close">&times;</button></div>'
3160
+ + '<div class="addsource-body">'
3161
+ + '<div class="addsource-type-toggle">'
3162
+ + '<button class="addsource-type-btn' + (isLocal ? ' active' : '') + '" data-type="local">Local Folder</button>'
3163
+ + '<button class="addsource-type-btn' + (!isLocal ? ' active' : '') + '" data-type="remote">Remote Repo</button>'
3164
+ + '</div>'
3165
+ + '<div class="addsource-error" id="addsource-error"></div>';
3166
+
3167
+ // Name field
3168
+ html += '<div class="addsource-field"><label>Name</label>'
3169
+ + '<input type="text" id="addsource-name" placeholder="e.g. my-project" value=""></div>';
3170
+
3171
+ if (isLocal) {
3172
+ // Path input
3173
+ html += '<div class="addsource-field"><label>Path</label>'
3174
+ + '<input type="text" id="addsource-path" placeholder="/absolute/path/to/folder" value=""></div>';
3175
+ } else {
3176
+ // URL
3177
+ html += '<div class="addsource-field"><label>URL</label>'
3178
+ + '<input type="text" id="addsource-url" placeholder="https://github.com/user/repo.git" value=""></div>';
3179
+ // Branch
3180
+ html += '<div class="addsource-field"><label>Branch</label>'
3181
+ + '<input type="text" id="addsource-branch" placeholder="main" value="main"></div>';
3182
+ }
3183
+
3184
+ // Root
3185
+ html += '<div class="addsource-field"><label>Root directory</label>'
3186
+ + '<input type="text" id="addsource-root" placeholder="project" value="project"></div>';
3187
+
3188
+ // Color picker
3189
+ html += '<div class="addsource-field"><label>Color</label><div class="addsource-colors">';
3190
+ for (var i = 0; i < _addSourceColors.length; i++) {
3191
+ var c = _addSourceColors[i];
3192
+ html += '<div class="addsource-color' + (c === st.color ? ' active' : '') + '" data-color="' + c + '" style="background:' + c + '"></div>';
3193
+ }
3194
+ html += '</div></div>';
3195
+
3196
+ html += '</div>'; // close body
3197
+ html += '<div class="addsource-footer">'
3198
+ + '<button class="btn" id="addsource-cancel">Cancel</button>'
3199
+ + '<button class="btn btn-primary" id="addsource-submit">Add Source</button>'
3200
+ + '</div>';
3201
+
3202
+ modal.innerHTML = html;
3203
+
3204
+ // Bind events
3205
+ document.getElementById('addsource-close').onclick = closeAddSourceModal;
3206
+ document.getElementById('addsource-cancel').onclick = closeAddSourceModal;
3207
+ document.getElementById('addsource-submit').onclick = submitAddSource;
3208
+
3209
+ // Type toggle
3210
+ modal.querySelectorAll('.addsource-type-btn').forEach(function(btn) {
3211
+ btn.onclick = function() {
3212
+ _addSourceState.type = btn.dataset.type;
3213
+ renderAddSourceModal();
3214
+ };
3215
+ });
2936
3216
 
2937
- var statusEl = document.getElementById('p-status');
2938
- if (statusEl) data.status = statusEl.value;
3217
+ // Color picker
3218
+ modal.querySelectorAll('.addsource-color').forEach(function(swatch) {
3219
+ swatch.onclick = function() {
3220
+ _addSourceState.color = swatch.dataset.color;
3221
+ modal.querySelectorAll('.addsource-color').forEach(function(s) { s.classList.remove('active'); });
3222
+ swatch.classList.add('active');
3223
+ };
3224
+ });
2939
3225
 
2940
- if (t === 'tasks' || t === 'epics') {
2941
- var priorityEl = document.getElementById('p-priority');
2942
- if (priorityEl && priorityEl.value) data.priority = priorityEl.value;
3226
+ // Auto-name from path/url
3227
+ var nameInput = document.getElementById('addsource-name');
3228
+ if (isLocal) {
3229
+ var pathInput = document.getElementById('addsource-path');
3230
+ if (pathInput) pathInput.addEventListener('input', function() {
3231
+ if (!nameInput.dataset.manual) {
3232
+ var parts = pathInput.value.replace(/\/+$/, '').split('/');
3233
+ nameInput.value = parts[parts.length - 1] || '';
3234
+ }
3235
+ });
3236
+ } else {
3237
+ var urlInput = document.getElementById('addsource-url');
3238
+ if (urlInput) urlInput.addEventListener('input', function() {
3239
+ if (!nameInput.dataset.manual) {
3240
+ var val = urlInput.value.replace(/\.git$/, '').replace(/\/+$/, '');
3241
+ var parts = val.split('/');
3242
+ nameInput.value = parts[parts.length - 1] || '';
3243
+ }
3244
+ });
2943
3245
  }
3246
+ if (nameInput) nameInput.addEventListener('input', function() {
3247
+ nameInput.dataset.manual = nameInput.value ? '1' : '';
3248
+ });
3249
+ }
2944
3250
 
2945
- if (t === 'tasks') {
2946
- var pointsEl = document.getElementById('p-points');
2947
- if (pointsEl && pointsEl.value) data.points = Number(pointsEl.value);
2948
-
2949
- var assignedEl = document.getElementById('p-assigned');
2950
- if (assignedEl && assignedEl.value.trim()) data.assigned = assignedEl.value.trim();
3251
+ function submitAddSource() {
3252
+ var errEl = document.getElementById('addsource-error');
3253
+ if (errEl) { errEl.textContent = ''; errEl.classList.remove('visible'); }
2951
3254
 
2952
- var sprintEl = document.getElementById('p-sprint');
2953
- if (sprintEl && sprintEl.value) data.sprint = sprintEl.value;
3255
+ var name = (document.getElementById('addsource-name') || {}).value || '';
3256
+ var root = (document.getElementById('addsource-root') || {}).value || '';
3257
+ var isLocal = _addSourceState.type === 'local';
2954
3258
 
2955
- var milestoneEl = document.getElementById('p-milestone');
2956
- if (milestoneEl) data.milestone = milestoneEl.value;
3259
+ var body = { name: name.trim(), type: _addSourceState.type, color: _addSourceState.color };
3260
+ if (root.trim()) body.root = root.trim();
2957
3261
 
2958
- var epicEl = document.getElementById('p-epic-select');
2959
- if (epicEl) data.epic = epicEl.value;
3262
+ if (isLocal) {
3263
+ body.path = ((document.getElementById('addsource-path') || {}).value || '').trim();
3264
+ } else {
3265
+ body.url = ((document.getElementById('addsource-url') || {}).value || '').trim();
3266
+ body.branch = ((document.getElementById('addsource-branch') || {}).value || 'main').trim();
2960
3267
  }
2961
3268
 
2962
- if (t === 'epics' || t === 'milestones') {
2963
- var milestoneEl = document.getElementById('p-milestone');
2964
- if (milestoneEl) data.milestone = milestoneEl.value;
2965
- }
3269
+ if (!body.name) return showAddSourceError('Name is required');
3270
+ if (isLocal && !body.path) return showAddSourceError('Path is required');
3271
+ if (!isLocal && !body.url) return showAddSourceError('URL is required');
2966
3272
 
2967
- if (t === 'milestones') {
2968
- var deadlineEl = document.getElementById('p-deadline');
2969
- if (deadlineEl && deadlineEl.value) data.deadline = deadlineEl.value;
2970
- }
3273
+ var submitBtn = document.getElementById('addsource-submit');
3274
+ if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Adding...'; }
2971
3275
 
2972
- if (panelState.editor) {
2973
- var editorContent = await getEditorMarkdown(panelState.editor);
2974
- if (editorContent) data.content = editorContent;
2975
- }
2976
-
2977
- // AI properties — read from tag inputs
2978
- var aiTagInputs = document.querySelectorAll('.ai-tag-input[data-ai-field]');
2979
- if (aiTagInputs.length > 0) {
2980
- var aiObj = {};
2981
- aiTagInputs.forEach(function(container) {
2982
- var field = container.dataset.aiField;
2983
- var vals = [];
2984
- container.querySelectorAll('.ai-tag-own .ai-tag-val').forEach(function(el) {
2985
- var v = el.textContent.trim();
2986
- if (v) vals.push(v);
3276
+ fetch('/api/sources', {
3277
+ method: 'POST',
3278
+ headers: { 'Content-Type': 'application/json' },
3279
+ body: JSON.stringify(body),
3280
+ }).then(function(r) { return r.json().then(function(d) { return { ok: r.ok, data: d }; }); })
3281
+ .then(function(res) {
3282
+ if (!res.ok) {
3283
+ showAddSourceError(res.data.error || 'Failed to add source');
3284
+ if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Add Source'; }
3285
+ return;
3286
+ }
3287
+ closeAddSourceModal();
3288
+ // Reload everything
3289
+ D.loaded = false;
3290
+ loadAll().then(function() {
3291
+ generateDynamicStyles();
3292
+ buildSidebar();
3293
+ buildViews();
3294
+ buildSourceRail();
3295
+ if (body.name) switchSource(body.name);
3296
+ renderSidebarLogo();
2987
3297
  });
2988
- if (vals.length > 0) aiObj[field] = vals;
3298
+ }).catch(function(err) {
3299
+ showAddSourceError(err.message || 'Network error');
3300
+ if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Add Source'; }
2989
3301
  });
2990
- if (Object.keys(aiObj).length > 0) data.ai = aiObj;
2991
- }
3302
+ }
2992
3303
 
2993
- var result = await createItem(t, data);
2994
- if (result) {
2995
- closePanel();
2996
- refreshData();
2997
- }
3304
+ function showAddSourceError(msg) {
3305
+ var el = document.getElementById('addsource-error');
3306
+ if (el) { el.textContent = msg; el.classList.add('visible'); }
2998
3307
  }
2999
3308
 
3000
- // Close panel on overlay click or Escape
3001
- document.getElementById('panel-overlay').addEventListener('click', closePanel);
3002
- document.addEventListener('keydown', function(e) {
3003
- if (e.key === 'Escape' && panelState.open) closePanel();
3004
- });
3309
+ /* ── SSE Hot Reload ────────────────────────────────────── */
3310
+ function connectSSE() {
3311
+ try {
3312
+ var src = new EventSource('/api/events');
3313
+ src.onmessage = function(e) {
3314
+ var data;
3315
+ try { data = JSON.parse(e.data); } catch(ex) { data = {}; }
3316
+
3317
+ if (data.type === 'project-changed') {
3318
+ D.activeSource = null;
3319
+ D.loaded = false;
3320
+ loadAll().then(function() {
3321
+ generateDynamicStyles();
3322
+ buildSidebar();
3323
+ buildViews();
3324
+ buildSourceRail();
3325
+ if (hasWorkspace()) {
3326
+ var firstSrc = D.config.workspace.sources[0];
3327
+ D.activeSource = null;
3328
+ switchSource(firstSrc ? firstSrc.name : null);
3329
+ } else {
3330
+ renderCurrentView();
3331
+ }
3332
+ renderSidebarLogo();
3333
+ });
3334
+ return;
3335
+ }
3336
+
3337
+ if (data.cssReload) {
3338
+ var link = document.getElementById('custom-theme');
3339
+ if (link) link.href = '/mdboard.css?' + Date.now();
3340
+ }
3341
+ refreshData();
3342
+ };
3343
+ src.onerror = function() { setTimeout(connectSSE, 5000); src.close(); };
3344
+ } catch(ex) { /* no SSE support */ }
3345
+ }
3005
3346
 
3006
3347
 
3007
3348
  /* ══════════════════════════════════════════════════════════════
@@ -3040,7 +3381,23 @@ function renderHistoryList(entries) {
3040
3381
  if (!body) return;
3041
3382
 
3042
3383
  if (entries.length === 0) {
3043
- body.innerHTML = '<div class="history-empty">No projects in history yet.<br>Open mdboard from a project directory to add it.</div>';
3384
+ body.innerHTML = '<div class="history-empty">No projects in history yet.<br>Open mdboard from a project directory to add it.</div>'
3385
+ + '<div class="history-open-row">'
3386
+ + '<input type="text" id="history-path-input" class="history-path-input" placeholder="/absolute/path/to/folder or https://github.com/user/repo">'
3387
+ + '<button class="btn btn-primary btn-sm" id="history-open-btn">Open</button>'
3388
+ + '</div>';
3389
+ var openBtn = document.getElementById('history-open-btn');
3390
+ var pathInput = document.getElementById('history-path-input');
3391
+ if (openBtn && pathInput) {
3392
+ openBtn.onclick = function() {
3393
+ var val = pathInput.value.trim();
3394
+ if (!val) return;
3395
+ switchProject(val);
3396
+ };
3397
+ pathInput.addEventListener('keydown', function(e) {
3398
+ if (e.key === 'Enter') { openBtn.click(); }
3399
+ });
3400
+ }
3044
3401
  return;
3045
3402
  }
3046
3403
 
@@ -3069,6 +3426,12 @@ function renderHistoryList(entries) {
3069
3426
  '</div>';
3070
3427
  }
3071
3428
 
3429
+ // Path input for opening a new project
3430
+ html += '<div class="history-open-row">'
3431
+ + '<input type="text" id="history-path-input" class="history-path-input" placeholder="/absolute/path/to/folder or https://github.com/user/repo">'
3432
+ + '<button class="btn btn-primary btn-sm" id="history-open-btn">Open</button>'
3433
+ + '</div>';
3434
+
3072
3435
  body.innerHTML = html;
3073
3436
 
3074
3437
  // Attach click handlers
@@ -3080,6 +3443,20 @@ function renderHistoryList(entries) {
3080
3443
  switchProject(itemPath);
3081
3444
  });
3082
3445
  }
3446
+
3447
+ // Open button handler
3448
+ var openBtn = document.getElementById('history-open-btn');
3449
+ var pathInput = document.getElementById('history-path-input');
3450
+ if (openBtn && pathInput) {
3451
+ openBtn.onclick = function() {
3452
+ var val = pathInput.value.trim();
3453
+ if (!val) return;
3454
+ switchProject(val);
3455
+ };
3456
+ pathInput.addEventListener('keydown', function(e) {
3457
+ if (e.key === 'Enter') { openBtn.click(); }
3458
+ });
3459
+ }
3083
3460
  }
3084
3461
 
3085
3462
  function switchProject(projectPath) {
@@ -3094,24 +3471,8 @@ function switchProject(projectPath) {
3094
3471
  .then(function(r) { return r.json(); })
3095
3472
  .then(function(data) {
3096
3473
  if (data && data.ok) {
3097
- closeHistoryModal();
3098
- // Reload everything — the SSE event will also trigger for other tabs
3099
- D.activeSource = null;
3100
- D.loaded = false;
3101
- loadAll().then(function() {
3102
- initFromConfig();
3103
- if (hasWorkspace()) {
3104
- buildSourceRail();
3105
- D.activeSource = null;
3106
- switchSource('overview');
3107
- } else {
3108
- renderAll();
3109
- renderSidebarLogo();
3110
- // Hide source rail if no workspace
3111
- var rail = document.getElementById('source-rail');
3112
- if (rail) rail.innerHTML = '';
3113
- }
3114
- });
3474
+ // Full reload to pick up new project's config, theme, and CSS
3475
+ window.location.reload();
3115
3476
  showToast('Switched to ' + (data.name || 'project'), 'success');
3116
3477
  } else {
3117
3478
  showToast('Error: ' + (data.error || 'Switch failed'), 'error');
@@ -3144,95 +3505,330 @@ function timeAgo(dateStr) {
3144
3505
 
3145
3506
 
3146
3507
  /* ══════════════════════════════════════════════════════════════
3147
- RENDER
3508
+ APP — Dynamic sidebar, view routing, settings, init
3148
3509
  ══════════════════════════════════════════════════════════════ */
3149
- function renderAll() {
3150
- renderHeader(); renderBoardFilters(); renderBoard(); renderTableControls(); renderTableBody(); renderMsFilters(); renderMilestones(); renderMetrics();
3151
- // Render overview if active
3152
- var overviewView = document.getElementById('view-overview');
3153
- if (overviewView && overviewView.classList.contains('active')) {
3154
- renderOverview();
3510
+
3511
+ /* ── Sidebar ─────────────────────────────────────────────── */
3512
+ function buildSidebar() {
3513
+ var nav = document.getElementById('sidebar-nav');
3514
+ if (!nav || !D.config || !D.config.ui || !D.config.ui.tabs) return;
3515
+
3516
+ var tabs = D.config.ui.tabs;
3517
+ var html = '';
3518
+ for (var i = 0; i < tabs.length; i++) {
3519
+ var tab = tabs[i];
3520
+ var icon = TAB_ICONS[tab.icon] || TAB_ICONS['board'];
3521
+ var cls = (D.activeTab === tab.id) ? ' class="active"' : '';
3522
+ var tooltip = tab.description ? ' title="' + escHtml(tab.description) + '"' : '';
3523
+ html += '<a href="#' + tab.id + '" data-view="' + tab.id + '"' + cls + tooltip + '>' + icon + '<span>' + escHtml(tab.label) + '</span></a>';
3155
3524
  }
3525
+ nav.innerHTML = html;
3156
3526
  }
3157
3527
 
3158
- function renderHeader() {
3159
- renderSidebarLogo();
3528
+ function buildViews() {
3529
+ var content = document.getElementById('app-content');
3530
+ if (!content || !D.config || !D.config.ui || !D.config.ui.tabs) return;
3160
3531
 
3161
- var mw = document.getElementById('h-milestone-wrap');
3162
- var am = D.milestones.find(function(m) { return m.status === 'active'; });
3163
- if (am) {
3164
- mw.style.display = '';
3165
- document.getElementById('h-milestone-name').textContent = am.title || am.id || '';
3166
- document.getElementById('h-milestone-progress').style.width = (am.progress || 0) + '%';
3167
- } else { mw.style.display = 'none'; }
3168
-
3169
- var sw = document.getElementById('h-sprint-wrap');
3170
- var as = D.sprints.find(function(s) { return s.status === 'active'; });
3171
- if (as) {
3172
- sw.style.display = '';
3173
- document.getElementById('h-sprint-name').textContent = as.goal || as.id || '';
3174
- var dr = daysRemaining(as.end_date);
3175
- document.getElementById('h-sprint-days').textContent = dr !== null ? (dr >= 0 ? dr + ' days left' : Math.abs(dr) + ' days over') : '';
3176
- } else { sw.style.display = 'none'; }
3177
-
3178
- var taskPlural = ENTITY_NAMES.task ? ENTITY_NAMES.task.plural : 'Tasks';
3179
- var total = D.tasks.length;
3180
- var done = D.tasks.filter(function(f) { return f.status === COMPLETED_STATUS; }).length;
3181
- var inProgStatus = (D.config && D.config.statuses && D.config.statuses.task) ?
3182
- ((D.config.statuses.task.find(function(s) { return s.icon === 'half-circle'; }) || {}).key || 'in-progress') : 'in-progress';
3183
- var inProg = D.tasks.filter(function(f) { return f.status === inProgStatus; }).length;
3184
- var vel = D.health && D.health.velocity != null ? D.health.velocity : (total ? Math.round(done / total * 100) : 0);
3185
- document.getElementById('h-stats').innerHTML =
3186
- '<div class="stat"><span class="stat-val">' + total + '</span><span class="stat-label">' + escHtml(taskPlural) + '</span></div>' +
3187
- '<div class="stat"><span class="stat-val" style="color:var(--success)">' + done + '</span><span class="stat-label">Done</span></div>' +
3188
- '<div class="stat"><span class="stat-val" style="color:var(--warning)">' + inProg + '</span><span class="stat-label">In Progress</span></div>' +
3189
- '<div class="stat"><span class="stat-val" style="color:var(--accent)">' + vel + '%</span><span class="stat-label">Velocity</span></div>';
3532
+ var tabs = D.config.ui.tabs;
3533
+ var html = '';
3534
+ for (var i = 0; i < tabs.length; i++) {
3535
+ var tab = tabs[i];
3536
+ var cls = (D.activeTab === tab.id) ? ' active' : '';
3537
+ html += '<div id="view-' + tab.id + '" class="view' + cls + '">';
3538
+ html += '<div class="view-toolbar" id="toolbar-' + tab.id + '"></div>';
3539
+ html += '<div class="view-body" id="body-' + tab.id + '"></div>';
3540
+ html += '</div>';
3541
+ }
3542
+ content.innerHTML = html;
3190
3543
  }
3191
3544
 
3192
- /* ══════════════════════════════════════════════════════════════
3193
- NAVIGATION
3194
- ══════════════════════════════════════════════════════════════ */
3195
- function switchView(name) {
3545
+ /* ── View Switching ──────────────────────────────────────── */
3546
+ function switchView(tabId) {
3547
+ D.activeTab = tabId;
3196
3548
  document.querySelectorAll('.view').forEach(function(v) { v.classList.remove('active'); });
3197
3549
  document.querySelectorAll('#sidebar-nav a[data-view]').forEach(function(a) { a.classList.remove('active'); });
3198
- var t = document.getElementById('view-' + name);
3550
+ var t = document.getElementById('view-' + tabId);
3199
3551
  if (t) t.classList.add('active');
3200
- var l = document.querySelector('#sidebar-nav a[data-view="' + name + '"]');
3552
+ var l = document.querySelector('#sidebar-nav a[data-view="' + tabId + '"]');
3201
3553
  if (l) l.classList.add('active');
3202
- // Persist view selection
3203
- try { localStorage.setItem('mdboard-view', name); } catch(e) {}
3204
- // Trigger overview rendering when switching to it
3205
- if (name === 'overview' && D.loaded) {
3206
- renderOverview();
3554
+ try { localStorage.setItem('mdboard-view', tabId); } catch(e) {}
3555
+ if (D.loaded) renderCurrentView();
3556
+ }
3557
+
3558
+ function cleanupNotesView() {
3559
+ // Destroy editor if active
3560
+ if (notesState.editor) {
3561
+ destroyEditor(notesState.editor);
3562
+ notesState.editor = null;
3207
3563
  }
3208
- // Trigger notes rendering when switching to it
3209
- if (name === 'notes' && D.loaded) {
3210
- renderNotes();
3564
+ // Reset inline styles that renderNotes applies to .view elements
3565
+ document.querySelectorAll('.view').forEach(function(v) {
3566
+ v.style.display = '';
3567
+ v.style.flexDirection = '';
3568
+ v.style.margin = '';
3569
+ v.style.height = '';
3570
+ });
3571
+ document.querySelectorAll('.view-body').forEach(function(b) {
3572
+ b.style.flex = '';
3573
+ b.style.overflow = '';
3574
+ });
3575
+ }
3576
+
3577
+ function renderCurrentView() {
3578
+ if (!D.config || !D.config.ui || !D.activeTab) return;
3579
+ var tabConfig = getTabConfig(D.activeTab);
3580
+ if (!tabConfig) return;
3581
+
3582
+ var toolbarEl = document.getElementById('toolbar-' + D.activeTab);
3583
+ var bodyEl = document.getElementById('body-' + D.activeTab);
3584
+ if (!toolbarEl || !bodyEl) return;
3585
+
3586
+ var layout = getActiveLayout(D.activeTab);
3587
+
3588
+ // Clean up notes view styles before rendering any view
3589
+ cleanupNotesView();
3590
+
3591
+ // Render toolbar: layout switcher + filters
3592
+ renderToolbar(toolbarEl, tabConfig, layout);
3593
+
3594
+ // Render header
3595
+ renderAppHeader();
3596
+
3597
+ // Dispatch to layout renderer
3598
+ switch (layout) {
3599
+ case 'board': renderBoard(bodyEl, tabConfig); break;
3600
+ case 'table': renderTable(bodyEl, tabConfig); break;
3601
+ case 'cards': renderCards(bodyEl, tabConfig); break;
3602
+ case 'list': renderList(bodyEl, tabConfig); break;
3603
+ case 'editor': renderNotes(bodyEl, tabConfig); break;
3604
+ case 'metrics': renderMetrics(bodyEl, tabConfig); break;
3605
+ default: bodyEl.innerHTML = '<div class="empty"><p>Unknown layout: ' + escHtml(layout) + '</p></div>';
3211
3606
  }
3212
3607
  }
3213
3608
 
3214
- document.getElementById('sidebar-nav').addEventListener('click', function(e) {
3215
- var a = e.target.closest('a[data-view]');
3216
- if (!a) return;
3217
- e.preventDefault();
3218
- switchView(a.dataset.view);
3219
- window.location.hash = a.dataset.view;
3220
- });
3609
+ function renderToolbar(container, tabConfig, activeLayout) {
3610
+ var html = '';
3221
3611
 
3222
- function handleHash() {
3223
- var hash = window.location.hash.replace('#', '');
3224
- if (!hash) {
3225
- try { hash = localStorage.getItem('mdboard-view') || ''; } catch(e) {}
3612
+ // Create button
3613
+ if (tabConfig.entity && isSourceWritable() && activeLayout !== 'editor' && activeLayout !== 'metrics') {
3614
+ html += '<button class="btn btn-sm btn-create" id="create-btn-' + tabConfig.id + '">+ New ' + escHtml(getEntitySingular(tabConfig.entity)) + '</button>';
3615
+ }
3616
+
3617
+ // Layout switcher
3618
+ if (tabConfig.layouts && tabConfig.layouts.length > 1) {
3619
+ html += '<div class="layout-switcher">';
3620
+ for (var i = 0; i < tabConfig.layouts.length; i++) {
3621
+ var l = tabConfig.layouts[i];
3622
+ var active = l === activeLayout ? ' active' : '';
3623
+ html += '<button class="layout-btn' + active + '" data-layout="' + l + '">' + escHtml(l.charAt(0).toUpperCase() + l.slice(1)) + '</button>';
3624
+ }
3625
+ html += '</div>';
3626
+ }
3627
+
3628
+ // Filters
3629
+ if (tabConfig.entity && activeLayout !== 'editor' && activeLayout !== 'metrics') {
3630
+ html += '<input type="text" class="filter-search" data-tab="' + tabConfig.id + '" placeholder="Search..." value="' + escHtml((D.tabFilters[tabConfig.id] || {})._search || '') + '">';
3631
+
3632
+ var filterFields = [];
3633
+ if (tabConfig.filters === 'auto') {
3634
+ filterFields = getAutoFilters(tabConfig.entity);
3635
+ } else if (Array.isArray(tabConfig.filters)) {
3636
+ filterFields = tabConfig.filters;
3637
+ }
3638
+
3639
+ var currentFilters = D.tabFilters[tabConfig.id] || {};
3640
+ for (var fi = 0; fi < filterFields.length; fi++) {
3641
+ var field = filterFields[fi];
3642
+ var values = getFilterValues(tabConfig.entity, field);
3643
+ var label = field.charAt(0).toUpperCase() + field.slice(1);
3644
+ var ancDef = getEntityDef(field);
3645
+ if (ancDef) label = ancDef.plural || label;
3646
+ var fieldDef = getEntityFields(tabConfig.entity)[field];
3647
+ if (fieldDef && fieldDef.label) label = fieldDef.label;
3648
+
3649
+ html += '<select class="filter-select" data-tab="' + tabConfig.id + '" data-field="' + field + '">';
3650
+ html += '<option value="">All ' + escHtml(label) + '</option>';
3651
+ for (var vi = 0; vi < values.length; vi++) {
3652
+ var v = values[vi];
3653
+ var sel = currentFilters[field] === v ? ' selected' : '';
3654
+ html += '<option value="' + escHtml(v) + '"' + sel + '>' + escHtml(getFieldLabel(tabConfig.entity, field, v)) + '</option>';
3655
+ }
3656
+ html += '</select>';
3657
+ }
3658
+ }
3659
+
3660
+ container.innerHTML = html;
3661
+
3662
+ // Event listeners
3663
+ var createBtn = document.getElementById('create-btn-' + tabConfig.id);
3664
+ if (createBtn) {
3665
+ createBtn.addEventListener('click', function() { openCreateDialog(tabConfig.entity); });
3666
+ }
3667
+
3668
+ container.querySelectorAll('.layout-btn').forEach(function(btn) {
3669
+ btn.addEventListener('click', function() {
3670
+ D.activeLayouts[tabConfig.id] = btn.dataset.layout;
3671
+ renderCurrentView();
3672
+ });
3673
+ });
3674
+
3675
+ var searchInput = container.querySelector('.filter-search[data-tab="' + tabConfig.id + '"]');
3676
+ if (searchInput) {
3677
+ searchInput.addEventListener('input', function() {
3678
+ if (!D.tabFilters[tabConfig.id]) D.tabFilters[tabConfig.id] = {};
3679
+ D.tabFilters[tabConfig.id]._search = searchInput.value;
3680
+ renderCurrentView();
3681
+ });
3226
3682
  }
3227
- hash = hash || 'board';
3228
- var valid = ['board','table','milestones','metrics','overview','notes'];
3229
- switchView(valid.indexOf(hash) !== -1 ? hash : 'board');
3683
+
3684
+ container.querySelectorAll('.filter-select').forEach(function(sel) {
3685
+ sel.addEventListener('change', function() {
3686
+ var tabId = sel.dataset.tab;
3687
+ var field = sel.dataset.field;
3688
+ if (!D.tabFilters[tabId]) D.tabFilters[tabId] = {};
3689
+ D.tabFilters[tabId][field] = sel.value;
3690
+ renderCurrentView();
3691
+ });
3692
+ });
3230
3693
  }
3231
- window.addEventListener('hashchange', handleHash);
3232
3694
 
3233
- /* ══════════════════════════════════════════════════════════════
3234
- SETTINGS PANEL
3235
- ══════════════════════════════════════════════════════════════ */
3695
+ /* ── Header ──────────────────────────────────────────────── */
3696
+ function renderAppHeader() {
3697
+ var header = document.getElementById('app-header');
3698
+ if (!header || !D.loaded) return;
3699
+
3700
+ renderSidebarLogo();
3701
+
3702
+ if (!D.config || !D.config.entities) { header.innerHTML = ''; return; }
3703
+
3704
+ // Find the leaf entity type for stats
3705
+ var leafType = null;
3706
+ var hierarchy = D.config.hierarchy || {};
3707
+ function findLeaf(node) {
3708
+ for (var t in node) {
3709
+ if (node[t].children) findLeaf(node[t].children);
3710
+ else leafType = t;
3711
+ }
3712
+ }
3713
+ findLeaf(hierarchy);
3714
+
3715
+ if (!leafType) { header.innerHTML = ''; return; }
3716
+
3717
+ var items = getEntities(leafType);
3718
+ var total = items.length;
3719
+ var done = items.filter(function(i) { return isCompletedStatus(i.status); }).length;
3720
+ var inProg = items.filter(function(i) {
3721
+ return i.status && !isCompletedStatus(i.status) && i.status !== 'todo' && i.status !== 'planned';
3722
+ }).length;
3723
+ var vel = total > 0 ? Math.round(done / total * 100) : 0;
3724
+
3725
+ var leafPlural = getEntityDef(leafType) ? getEntityDef(leafType).plural : 'Items';
3726
+
3727
+ header.innerHTML =
3728
+ '<div class="global-search-wrap" id="global-search-wrap">' +
3729
+ '<svg class="global-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>' +
3730
+ '<input type="text" class="global-search-input" id="global-search" placeholder="Search everything... (Ctrl+K)" autocomplete="off">' +
3731
+ '<div class="global-search-results" id="global-search-results"></div>' +
3732
+ '</div>' +
3733
+ '<div id="h-stats" style="display:flex;gap:20px;margin-left:auto">' +
3734
+ '<div class="stat"><span class="stat-val">' + total + '</span><span class="stat-label">' + escHtml(leafPlural) + '</span></div>' +
3735
+ '<div class="stat"><span class="stat-val" style="color:var(--success)">' + done + '</span><span class="stat-label">Done</span></div>' +
3736
+ '<div class="stat"><span class="stat-val" style="color:var(--warning)">' + inProg + '</span><span class="stat-label">Active</span></div>' +
3737
+ '<div class="stat"><span class="stat-val" style="color:var(--accent)">' + vel + '%</span><span class="stat-label">Progress</span></div>' +
3738
+ '</div>';
3739
+
3740
+ var searchInput = document.getElementById('global-search');
3741
+ if (searchInput && !searchInput._bound) {
3742
+ searchInput._bound = true;
3743
+ searchInput.addEventListener('input', function() { globalSearch(searchInput.value); });
3744
+ searchInput.addEventListener('focus', function() { if (searchInput.value) globalSearch(searchInput.value); });
3745
+ searchInput.addEventListener('keydown', function(e) {
3746
+ var results = document.getElementById('global-search-results');
3747
+ var items = results ? results.querySelectorAll('.gs-item') : [];
3748
+ var active = results ? results.querySelector('.gs-item.active') : null;
3749
+ var idx = active ? Array.prototype.indexOf.call(items, active) : -1;
3750
+ if (e.key === 'ArrowDown') { e.preventDefault(); selectGsItem(items, idx + 1); }
3751
+ else if (e.key === 'ArrowUp') { e.preventDefault(); selectGsItem(items, idx - 1); }
3752
+ else if (e.key === 'Enter' && active) { e.preventDefault(); active.click(); }
3753
+ else if (e.key === 'Escape') { searchInput.blur(); closeGlobalSearch(); }
3754
+ });
3755
+ document.addEventListener('click', function(e) {
3756
+ if (!e.target.closest('#global-search-wrap')) closeGlobalSearch();
3757
+ });
3758
+ }
3759
+ }
3760
+
3761
+ function selectGsItem(items, idx) {
3762
+ if (!items.length) return;
3763
+ items.forEach(function(el) { el.classList.remove('active'); });
3764
+ if (idx < 0) idx = items.length - 1;
3765
+ if (idx >= items.length) idx = 0;
3766
+ items[idx].classList.add('active');
3767
+ items[idx].scrollIntoView({ block: 'nearest' });
3768
+ }
3769
+
3770
+ function globalSearch(query) {
3771
+ var results = document.getElementById('global-search-results');
3772
+ if (!results) return;
3773
+ if (!query || query.length < 2) { results.innerHTML = ''; results.style.display = 'none'; return; }
3774
+
3775
+ var q = query.toLowerCase();
3776
+ var matches = [];
3777
+ var types = D.config && D.config.entities ? Object.keys(D.config.entities) : [];
3778
+
3779
+ for (var ti = 0; ti < types.length; ti++) {
3780
+ var type = types[ti];
3781
+ var items = getEntities(type);
3782
+ for (var i = 0; i < items.length; i++) {
3783
+ var item = items[i];
3784
+ var idMatch = (item.id || '').toLowerCase().indexOf(q) !== -1;
3785
+ var titleMatch = (item.title || '').toLowerCase().indexOf(q) !== -1;
3786
+ if (idMatch || titleMatch) {
3787
+ matches.push({ type: type, item: item });
3788
+ if (matches.length >= 12) break;
3789
+ }
3790
+ }
3791
+ if (matches.length >= 12) break;
3792
+ }
3793
+
3794
+ if (matches.length === 0) {
3795
+ results.innerHTML = '<div class="gs-empty">No results</div>';
3796
+ results.style.display = 'block';
3797
+ return;
3798
+ }
3799
+
3800
+ var html = '';
3801
+ for (var mi = 0; mi < matches.length; mi++) {
3802
+ var m = matches[mi];
3803
+ var def = getEntityDef(m.type);
3804
+ var label = def ? def.singular : m.type;
3805
+ html += '<div class="gs-item" data-type="' + escHtml(m.type) + '" data-id="' + escHtml(m.item.id) + '">' +
3806
+ statusIcon(m.type, m.item.status) +
3807
+ '<span class="gs-title">' + escHtml(m.item.title || m.item.id) + '</span>' +
3808
+ '<span class="gs-meta">' + escHtml(m.item.id) + '</span>' +
3809
+ '<span class="gs-type">' + escHtml(label) + '</span>' +
3810
+ '</div>';
3811
+ }
3812
+ results.innerHTML = html;
3813
+ results.style.display = 'block';
3814
+
3815
+ results.querySelectorAll('.gs-item').forEach(function(el) {
3816
+ el.addEventListener('click', function() {
3817
+ var type = el.dataset.type;
3818
+ var item = getEntities(type).find(function(i) { return i.id === el.dataset.id; });
3819
+ if (item) openPanel(type, item);
3820
+ closeGlobalSearch();
3821
+ document.getElementById('global-search').value = '';
3822
+ });
3823
+ });
3824
+ }
3825
+
3826
+ function closeGlobalSearch() {
3827
+ var results = document.getElementById('global-search-results');
3828
+ if (results) { results.innerHTML = ''; results.style.display = 'none'; }
3829
+ }
3830
+
3831
+ /* ── Settings ────────────────────────────────────────────── */
3236
3832
  var _settingsState = { selectedTheme: null, originalTheme: null };
3237
3833
 
3238
3834
  function openSettings() {
@@ -3291,10 +3887,7 @@ function renderThemeGrid() {
3291
3887
 
3292
3888
  function saveTheme() {
3293
3889
  var themeId = _settingsState.selectedTheme;
3294
- if (!themeId) {
3295
- closeSettings();
3296
- return;
3297
- }
3890
+ if (!themeId) { closeSettings(); return; }
3298
3891
  var toggle = document.getElementById('theme-default-toggle');
3299
3892
  var setDefault = toggle ? toggle.checked : false;
3300
3893
  var css = generateThemeCss(themeId);
@@ -3305,10 +3898,10 @@ function saveTheme() {
3305
3898
  body: JSON.stringify({ themeId: themeId, css: css, setDefault: setDefault })
3306
3899
  }).then(function(r) { return r.json(); }).then(function(data) {
3307
3900
  if (data && data.ok) {
3308
- revertThemePreview();
3309
- reloadCustomCss();
3310
3901
  D.config.theme = themeId;
3902
+ _settingsState.originalTheme = themeId;
3311
3903
  _settingsState.selectedTheme = null;
3904
+ applyTheme(themeId);
3312
3905
  showToast('Theme saved: ' + (THEMES[themeId] ? THEMES[themeId].name : themeId), 'success');
3313
3906
  closeSettings();
3314
3907
  } else {
@@ -3319,96 +3912,85 @@ function saveTheme() {
3319
3912
  });
3320
3913
  }
3321
3914
 
3322
- document.getElementById('settings-toggle').addEventListener('click', function(e) {
3323
- e.preventDefault();
3324
- openSettings();
3325
- });
3326
-
3327
3915
  document.getElementById('settings-close').addEventListener('click', closeSettings);
3328
3916
  document.getElementById('settings-overlay').addEventListener('click', closeSettings);
3329
-
3330
3917
  document.getElementById('theme-grid').addEventListener('click', function(e) {
3331
3918
  var card = e.target.closest('.theme-card');
3332
3919
  if (!card) return;
3333
- var themeId = card.dataset.theme;
3334
- _settingsState.selectedTheme = themeId;
3335
- applyTheme(themeId);
3920
+ _settingsState.selectedTheme = card.dataset.theme;
3921
+ applyTheme(card.dataset.theme);
3336
3922
  renderThemeGrid();
3337
3923
  });
3338
-
3339
3924
  document.getElementById('settings-save').addEventListener('click', saveTheme);
3340
3925
 
3926
+ /* ── Navigation ──────────────────────────────────────────── */
3927
+ document.getElementById('sidebar-nav').addEventListener('click', function(e) {
3928
+ var a = e.target.closest('a[data-view]');
3929
+ if (!a) return;
3930
+ e.preventDefault();
3931
+ switchView(a.dataset.view);
3932
+ window.location.hash = a.dataset.view;
3933
+ });
3934
+
3935
+ function handleHash() {
3936
+ var hash = window.location.hash.replace('#', '');
3937
+ if (!hash) {
3938
+ try { hash = localStorage.getItem('mdboard-view') || ''; } catch(e) {}
3939
+ }
3940
+ if (!hash && D.config && D.config.ui && D.config.ui.tabs && D.config.ui.tabs.length > 0) {
3941
+ hash = D.config.ui.tabs[0].id;
3942
+ }
3943
+ return hash || 'tasks';
3944
+ }
3945
+ window.addEventListener('hashchange', function() { switchView(handleHash()); });
3946
+
3341
3947
  /* ══════════════════════════════════════════════════════════════
3342
3948
  INIT
3343
3949
  ══════════════════════════════════════════════════════════════ */
3344
3950
  (async function() {
3345
- handleHash();
3346
3951
  await loadAll();
3347
- initFromConfig();
3952
+ generateDynamicStyles();
3953
+
3954
+ // Determine initial tab
3955
+ D.activeTab = handleHash();
3348
3956
 
3349
- // Sidebar logo always shows project name
3957
+ // Build dynamic UI
3958
+ buildSidebar();
3959
+ buildViews();
3350
3960
  renderSidebarLogo();
3351
3961
 
3352
- // Workspace detection: build source rail + restore last source
3353
- if (hasWorkspace()) {
3354
- // Create overview tab (hidden by default)
3355
- var overviewTab = document.querySelector('#sidebar-nav a[data-view="overview"]');
3356
- if (!overviewTab) {
3357
- var nav = document.getElementById('sidebar-nav');
3358
- var metricsLink = document.querySelector('#sidebar-nav a[data-view="metrics"]');
3359
- var overviewLink = document.createElement('a');
3360
- overviewLink.href = '#overview';
3361
- overviewLink.dataset.view = 'overview';
3362
- overviewLink.style.display = 'none';
3363
- overviewLink.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="9"/><path d="M12 3v18M3 12h18"/></svg><span>Overview</span>';
3364
- if (metricsLink && metricsLink.nextSibling) {
3365
- nav.insertBefore(overviewLink, metricsLink.nextSibling);
3366
- } else {
3367
- nav.appendChild(overviewLink);
3368
- }
3369
- }
3962
+ // Always build the source rail (switch project button)
3963
+ buildSourceRail();
3370
3964
 
3371
- // Restore last source or default to overview
3965
+ // Workspace source switching
3966
+ if (hasWorkspace()) {
3372
3967
  var savedSource = null;
3373
3968
  try { savedSource = localStorage.getItem('mdboard-source'); } catch(e) {}
3374
- var validSource = savedSource && (savedSource === 'overview' || D.config.workspace.sources.some(function(s) { return s.name === savedSource; }));
3375
- var initialSource = validSource ? savedSource : 'overview';
3969
+ var validSource = savedSource &&
3970
+ D.config.workspace.sources.some(function(s) { return s.name === savedSource; });
3971
+ var initialSource = validSource ? savedSource : D.config.workspace.sources[0].name;
3376
3972
 
3377
- buildSourceRail();
3378
- // switchSource skips if same as current, so set activeSource first then trigger
3379
3973
  D.activeSource = null;
3380
3974
  switchSource(initialSource);
3381
- // Wait for switchSource data reload before continuing
3382
3975
  await loadAll();
3383
- initFromConfig();
3976
+ generateDynamicStyles();
3384
3977
  }
3385
3978
 
3386
- renderAll();
3979
+ // Initial render
3980
+ switchView(D.activeTab);
3387
3981
  connectSSE();
3388
-
3389
- // Auto-open history modal if no project
3390
3982
  checkAutoOpenHistory();
3391
3983
 
3392
- // Keyboard shortcut: Ctrl/Cmd+K to open history modal
3984
+ // Keyboard shortcuts
3393
3985
  document.addEventListener('keydown', function(e) {
3394
3986
  if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
3395
3987
  e.preventDefault();
3396
- openHistoryModal();
3988
+ var search = document.getElementById('global-search');
3989
+ if (search) { search.focus(); search.select(); }
3397
3990
  }
3398
3991
  });
3399
-
3400
- // Load AI suggestions for autocomplete
3401
- fetchJson('/api/ai-suggestions').then(function(data) {
3402
- D.aiSuggestions = data || { skills: [], agents: [], mcps: [], commands: [], context: [] };
3403
- });
3404
-
3405
- // Load overview links in background for reverse link display
3406
- if (hasWorkspace()) {
3407
- fetchJson('/api/overview/links').then(function(data) {
3408
- D.overviewLinks = data;
3409
- });
3410
- }
3411
3992
  })();
3993
+
3412
3994
  </script>
3413
3995
  </body>
3414
3996
  </html>