mdboard 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,194 @@
1
+ /**
2
+ * mdboard — Consistency checker
3
+ *
4
+ * Detects dangling references and inconsistencies in the project model.
5
+ * With --fix, auto-corrects fixable issues.
6
+ *
7
+ * Usage:
8
+ * mdboard sync Report issues
9
+ * mdboard sync --fix Auto-fix fixable issues
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { buildModel } = require('./cli');
15
+ const { updateMarkdownFile } = require('../core/scanner');
16
+
17
+ /**
18
+ * Run consistency checks on the project model.
19
+ *
20
+ * @param {string} projectDir - Workspace root directory
21
+ * @param {{ fix?: boolean }} opts - Options
22
+ */
23
+ function runSync(projectDir, opts) {
24
+ const fix = opts && opts.fix;
25
+ const { model, config, projectPath } = buildModel(projectDir);
26
+
27
+ const issues = [];
28
+
29
+ const taskIds = new Set(model.tasks.map(t => t.id));
30
+ const sprintIds = new Set(model.sprints.map(s => s.id));
31
+ const epicIds = new Set(model.epics.map(e => e.id));
32
+ const milestoneIds = new Set(model.milestones.map(m => m.id));
33
+
34
+ // 1. sprint.features[] references tasks that don't exist
35
+ for (const sprint of model.sprints) {
36
+ if (!sprint.features || !Array.isArray(sprint.features)) continue;
37
+ const missing = sprint.features.filter(f => !taskIds.has(f));
38
+ if (missing.length > 0) {
39
+ issues.push({
40
+ type: 'error',
41
+ fixable: true,
42
+ message: `Sprint ${sprint.id}: features[] references missing tasks: ${missing.join(', ')}`,
43
+ fix: () => {
44
+ const newFeatures = sprint.features.filter(f => taskIds.has(f));
45
+ updateMarkdownFile(projectPath, sprint._file, { features: newFeatures });
46
+ },
47
+ });
48
+ }
49
+ }
50
+
51
+ // 2. task.sprint references sprint that doesn't exist
52
+ for (const task of model.tasks) {
53
+ if (!task.sprint) continue;
54
+ if (!sprintIds.has(task.sprint)) {
55
+ issues.push({
56
+ type: 'error',
57
+ fixable: true,
58
+ message: `Task ${task.id}: sprint "${task.sprint}" does not exist`,
59
+ fix: () => {
60
+ updateMarkdownFile(projectPath, task._file, { sprint: null });
61
+ },
62
+ });
63
+ }
64
+ }
65
+
66
+ // 3. epic.dependencies[] references epics that don't exist
67
+ for (const epic of model.epics) {
68
+ if (!epic.dependencies || !Array.isArray(epic.dependencies)) continue;
69
+ const missing = epic.dependencies.filter(d => !epicIds.has(d));
70
+ if (missing.length > 0) {
71
+ issues.push({
72
+ type: 'error',
73
+ fixable: true,
74
+ message: `Epic ${epic.id}: dependencies[] references missing epics: ${missing.join(', ')}`,
75
+ fix: () => {
76
+ const newDeps = epic.dependencies.filter(d => epicIds.has(d));
77
+ updateMarkdownFile(projectPath, epic._file, { dependencies: newDeps });
78
+ },
79
+ });
80
+ }
81
+ }
82
+
83
+ // 4. milestone.tracks[] references milestones that don't exist
84
+ for (const ms of model.milestones) {
85
+ if (!ms.tracks || !Array.isArray(ms.tracks)) continue;
86
+ const missing = ms.tracks.filter(t => {
87
+ // tracks can reference source:MS-ID format
88
+ if (String(t).includes(':')) {
89
+ const parts = String(t).split(':');
90
+ const refId = parts[1];
91
+ return !model.milestones.some(m => m.id === t || m._originalId === refId || m.id === refId);
92
+ }
93
+ return !milestoneIds.has(t);
94
+ });
95
+ if (missing.length > 0) {
96
+ issues.push({
97
+ type: 'warning',
98
+ fixable: false,
99
+ message: `Milestone ${ms.id}: tracks[] references missing milestones: ${missing.join(', ')}`,
100
+ });
101
+ }
102
+ }
103
+
104
+ // 5. Duplicate IDs in the same collection
105
+ const collections = [
106
+ { name: 'tasks', items: model.tasks },
107
+ { name: 'milestones', items: model.milestones },
108
+ { name: 'epics', items: model.epics },
109
+ { name: 'sprints', items: model.sprints },
110
+ ];
111
+
112
+ for (const col of collections) {
113
+ const seen = new Map();
114
+ for (const item of col.items) {
115
+ if (!item.id) continue;
116
+ if (seen.has(item.id)) {
117
+ issues.push({
118
+ type: 'error',
119
+ fixable: false,
120
+ message: `Duplicate ${col.name.slice(0, -1)} ID "${item.id}": ${seen.get(item.id)} and ${item._file}`,
121
+ });
122
+ } else {
123
+ seen.set(item.id, item._file);
124
+ }
125
+ }
126
+ }
127
+
128
+ // 6. Tasks under epic directory without epic README.md
129
+ const msDir = config.entities.milestone.dir;
130
+ const epicDir = config.entities.epic.dir;
131
+ const taskDir = config.entities.task.dir;
132
+
133
+ for (const task of model.tasks) {
134
+ if (!task._epic || !task._milestone) continue;
135
+ const epicReadmePath = path.join(projectPath, msDir, task._milestone, epicDir, task._epic, 'README.md');
136
+ if (!fs.existsSync(epicReadmePath)) {
137
+ issues.push({
138
+ type: 'warning',
139
+ fixable: false,
140
+ message: `Task ${task.id}: parent epic directory "${task._epic}" has no README.md`,
141
+ });
142
+ }
143
+ }
144
+
145
+ // 7. Sprints under milestone without milestone README.md
146
+ for (const sprint of model.sprints) {
147
+ if (!sprint._milestone) continue;
148
+ const msReadmePath = path.join(projectPath, msDir, sprint._milestone, 'README.md');
149
+ if (!fs.existsSync(msReadmePath)) {
150
+ issues.push({
151
+ type: 'warning',
152
+ fixable: false,
153
+ message: `Sprint ${sprint.id}: parent milestone directory "${sprint._milestone}" has no README.md`,
154
+ });
155
+ }
156
+ }
157
+
158
+ // Report
159
+ if (issues.length === 0) {
160
+ console.log('\n mdboard sync — No issues found.\n');
161
+ return;
162
+ }
163
+
164
+ const errors = issues.filter(i => i.type === 'error');
165
+ const warnings = issues.filter(i => i.type === 'warning');
166
+ const fixable = issues.filter(i => i.fixable);
167
+
168
+ console.log('');
169
+ for (const issue of issues) {
170
+ const prefix = issue.type === 'error' ? ' ERROR' : ' WARN ';
171
+ const suffix = issue.fixable ? ' [fixable]' : '';
172
+ console.log(`${prefix}: ${issue.message}${suffix}`);
173
+ }
174
+ console.log('');
175
+
176
+ if (fix && fixable.length > 0) {
177
+ for (const issue of fixable) {
178
+ issue.fix();
179
+ }
180
+ console.log(` mdboard sync --fix — Fixed ${fixable.length} issue${fixable.length > 1 ? 's' : ''}.`);
181
+ if (warnings.length > 0) {
182
+ console.log(` ${warnings.length} warning${warnings.length > 1 ? 's' : ''} remain (not auto-fixable).`);
183
+ }
184
+ console.log('');
185
+ } else {
186
+ console.log(` mdboard sync — ${issues.length} issue${issues.length > 1 ? 's' : ''} (${errors.length} error${errors.length > 1 ? 's' : ''}, ${warnings.length} warning${warnings.length > 1 ? 's' : ''}).${fixable.length > 0 ? ` ${fixable.length} fixable.` : ''}`);
187
+ if (fixable.length > 0) {
188
+ console.log(' Run `mdboard sync --fix` to auto-correct.');
189
+ }
190
+ console.log('');
191
+ }
192
+ }
193
+
194
+ module.exports = { runSync };
@@ -0,0 +1,142 @@
1
+ /**
2
+ * mdboard theme — Set or list available themes
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const os = require('os');
8
+
9
+ const THEME_IDS = [
10
+ 'default-dark',
11
+ 'linear-dark', 'linear-light',
12
+ 'jira-dark', 'jira-light',
13
+ 'catppuccin-mocha', 'catppuccin-latte',
14
+ 'ayu-dark', 'ayu-light',
15
+ 'tokyo-night', 'tokyo-night-storm',
16
+ 'notion-light', 'notion-dark',
17
+ 'github-dark', 'github-light',
18
+ 'dracula', 'nord',
19
+ 'solarized-dark', 'solarized-light',
20
+ 'one-dark',
21
+ 'gruvbox-dark',
22
+ ];
23
+
24
+ const THEME_NAMES = {
25
+ 'default-dark': 'Default Dark',
26
+ 'linear-dark': 'Linear Dark', 'linear-light': 'Linear Light',
27
+ 'jira-dark': 'Jira Dark', 'jira-light': 'Jira Light',
28
+ 'catppuccin-mocha': 'Catppuccin Mocha', 'catppuccin-latte': 'Catppuccin Latte',
29
+ 'ayu-dark': 'Ayu Dark', 'ayu-light': 'Ayu Light',
30
+ 'tokyo-night': 'Tokyo Night', 'tokyo-night-storm': 'Tokyo Night Storm',
31
+ 'notion-light': 'Notion Light', 'notion-dark': 'Notion Dark',
32
+ 'github-dark': 'GitHub Dark', 'github-light': 'GitHub Light',
33
+ 'dracula': 'Dracula', 'nord': 'Nord',
34
+ 'solarized-dark': 'Solarized Dark', 'solarized-light': 'Solarized Light',
35
+ 'one-dark': 'One Dark',
36
+ 'gruvbox-dark': 'Gruvbox Dark',
37
+ };
38
+
39
+ const THEME_VARS = {
40
+ 'default-dark': {bg:'#0A0A0B',surface:'#141415',surface2:'#1A1A1C',border:'#232326',border2:'#2E2E33',text:'#E8E8ED',text2:'#8B8B93',text3:'#5A5A63',accent:'#5B6EF5','accent-dim':'rgba(91,110,245,.15)',success:'#2EA043','success-dim':'rgba(46,160,67,.15)',warning:'#D4A72C','warning-dim':'rgba(212,167,44,.15)',danger:'#DA3633','danger-dim':'rgba(218,54,51,.15)',purple:'#8B5CF6','purple-dim':'rgba(139,92,246,.15)'},
41
+ 'linear-dark': {bg:'#101012',surface:'#1A1A1F',surface2:'#222228',border:'#2C2C35',border2:'#38384A',text:'#E2E2E9',text2:'#8E8E9E',text3:'#5C5C6E',accent:'#5E6AD2','accent-dim':'rgba(94,106,210,.15)',success:'#4DA771','success-dim':'rgba(77,167,113,.15)',warning:'#F2C94C','warning-dim':'rgba(242,201,76,.15)',danger:'#EB5757','danger-dim':'rgba(235,87,87,.15)',purple:'#BB87FC','purple-dim':'rgba(187,135,252,.15)'},
42
+ 'linear-light': {bg:'#F9F9FB',surface:'#FFFFFF',surface2:'#F0F0F4',border:'#E0E0E6',border2:'#D0D0D8',text:'#1A1A2E',text2:'#6B6B80',text3:'#9090A0',accent:'#5E6AD2','accent-dim':'rgba(94,106,210,.1)',success:'#4DA771','success-dim':'rgba(77,167,113,.1)',warning:'#D4A017','warning-dim':'rgba(212,160,23,.1)',danger:'#EB5757','danger-dim':'rgba(235,87,87,.1)',purple:'#9B6DD7','purple-dim':'rgba(155,109,215,.1)'},
43
+ 'jira-dark': {bg:'#0D1117',surface:'#161B22',surface2:'#1C2333',border:'#293040',border2:'#344050',text:'#DEE4EC',text2:'#8C96A5',text3:'#5A6577',accent:'#2684FF','accent-dim':'rgba(38,132,255,.15)',success:'#36B37E','success-dim':'rgba(54,179,126,.15)',warning:'#FFAB00','warning-dim':'rgba(255,171,0,.15)',danger:'#FF5630','danger-dim':'rgba(255,86,48,.15)',purple:'#6554C0','purple-dim':'rgba(101,84,192,.15)'},
44
+ 'jira-light': {bg:'#F4F5F7',surface:'#FFFFFF',surface2:'#EBECF0',border:'#DFE1E6',border2:'#C1C7D0',text:'#172B4D',text2:'#6B778C',text3:'#97A0AF',accent:'#0052CC','accent-dim':'rgba(0,82,204,.1)',success:'#36B37E','success-dim':'rgba(54,179,126,.1)',warning:'#FF991F','warning-dim':'rgba(255,153,31,.1)',danger:'#FF5630','danger-dim':'rgba(255,86,48,.1)',purple:'#6554C0','purple-dim':'rgba(101,84,192,.1)'},
45
+ 'catppuccin-mocha': {bg:'#1E1E2E',surface:'#262637',surface2:'#313244',border:'#3B3B52',border2:'#45475A',text:'#CDD6F4',text2:'#A6ADC8',text3:'#6C7086',accent:'#89B4FA','accent-dim':'rgba(137,180,250,.15)',success:'#A6E3A1','success-dim':'rgba(166,227,161,.15)',warning:'#F9E2AF','warning-dim':'rgba(249,226,175,.15)',danger:'#F38BA8','danger-dim':'rgba(243,139,168,.15)',purple:'#CBA6F7','purple-dim':'rgba(203,166,247,.15)'},
46
+ 'catppuccin-latte': {bg:'#EFF1F5',surface:'#FFFFFF',surface2:'#E6E9EF',border:'#CCD0DA',border2:'#BCC0CC',text:'#4C4F69',text2:'#6C6F85',text3:'#9CA0B0',accent:'#1E66F5','accent-dim':'rgba(30,102,245,.1)',success:'#40A02B','success-dim':'rgba(64,160,43,.1)',warning:'#DF8E1D','warning-dim':'rgba(223,142,29,.1)',danger:'#D20F39','danger-dim':'rgba(210,15,57,.1)',purple:'#8839EF','purple-dim':'rgba(136,57,239,.1)'},
47
+ 'ayu-dark': {bg:'#0B0E14',surface:'#0F1219',surface2:'#151920',border:'#1E222A',border2:'#272D38',text:'#BFBDB6',text2:'#6C7380',text3:'#4A5060',accent:'#E6B450','accent-dim':'rgba(230,180,80,.15)',success:'#7FD962','success-dim':'rgba(127,217,98,.15)',warning:'#FFB454','warning-dim':'rgba(255,180,84,.15)',danger:'#F07178','danger-dim':'rgba(240,113,120,.15)',purple:'#D2A6FF','purple-dim':'rgba(210,166,255,.15)'},
48
+ 'ayu-light': {bg:'#F8F9FA',surface:'#FFFFFF',surface2:'#F0F1F2',border:'#E0E1E2',border2:'#D4D5D6',text:'#5C6166',text2:'#787B80',text3:'#ACB0B5',accent:'#FF9940','accent-dim':'rgba(255,153,64,.1)',success:'#6CBF43','success-dim':'rgba(108,191,67,.1)',warning:'#F2AE49','warning-dim':'rgba(242,174,73,.1)',danger:'#F07171','danger-dim':'rgba(240,113,113,.1)',purple:'#A37ACC','purple-dim':'rgba(163,122,204,.1)'},
49
+ 'tokyo-night': {bg:'#1A1B26',surface:'#1F2335',surface2:'#24283B',border:'#2F3449',border2:'#3B4261',text:'#C0CAF5',text2:'#7982A9',text3:'#565F89',accent:'#7AA2F7','accent-dim':'rgba(122,162,247,.15)',success:'#9ECE6A','success-dim':'rgba(158,206,106,.15)',warning:'#E0AF68','warning-dim':'rgba(224,175,104,.15)',danger:'#F7768E','danger-dim':'rgba(247,118,142,.15)',purple:'#BB9AF7','purple-dim':'rgba(187,154,247,.15)'},
50
+ 'tokyo-night-storm': {bg:'#24283B',surface:'#292E42',surface2:'#2F3549',border:'#3B4261',border2:'#464F78',text:'#C0CAF5',text2:'#7982A9',text3:'#565F89',accent:'#7AA2F7','accent-dim':'rgba(122,162,247,.15)',success:'#9ECE6A','success-dim':'rgba(158,206,106,.15)',warning:'#E0AF68','warning-dim':'rgba(224,175,104,.15)',danger:'#F7768E','danger-dim':'rgba(247,118,142,.15)',purple:'#BB9AF7','purple-dim':'rgba(187,154,247,.15)'},
51
+ 'notion-light': {bg:'#F7F6F3',surface:'#FFFFFF',surface2:'#EEEEEC',border:'#E3E2DE',border2:'#D4D3CF',text:'#37352F',text2:'#6B6B6B',text3:'#9B9A97',accent:'#2383E2','accent-dim':'rgba(35,131,226,.1)',success:'#0F7B0F','success-dim':'rgba(15,123,15,.1)',warning:'#CB7B00','warning-dim':'rgba(203,123,0,.1)',danger:'#E03E3E','danger-dim':'rgba(224,62,62,.1)',purple:'#6940A5','purple-dim':'rgba(105,64,165,.1)'},
52
+ 'notion-dark': {bg:'#191919',surface:'#202020',surface2:'#2B2B2B',border:'#363636',border2:'#444444',text:'#E3E2DE',text2:'#9B9B9B',text3:'#6B6B6B',accent:'#529CCA','accent-dim':'rgba(82,156,202,.15)',success:'#4DAB9A','success-dim':'rgba(77,171,154,.15)',warning:'#CB7B00','warning-dim':'rgba(203,123,0,.15)',danger:'#E03E3E','danger-dim':'rgba(224,62,62,.15)',purple:'#9A6DD7','purple-dim':'rgba(154,109,215,.15)'},
53
+ 'github-dark': {bg:'#0D1117',surface:'#161B22',surface2:'#1C2128',border:'#30363D',border2:'#3D444D',text:'#E6EDF3',text2:'#8B949E',text3:'#6E7681',accent:'#58A6FF','accent-dim':'rgba(88,166,255,.15)',success:'#3FB950','success-dim':'rgba(63,185,80,.15)',warning:'#D29922','warning-dim':'rgba(210,153,34,.15)',danger:'#F85149','danger-dim':'rgba(248,81,73,.15)',purple:'#BC8CFF','purple-dim':'rgba(188,140,255,.15)'},
54
+ 'github-light': {bg:'#F6F8FA',surface:'#FFFFFF',surface2:'#EAEEF2',border:'#D0D7DE',border2:'#BCC3CB',text:'#1F2328',text2:'#656D76',text3:'#8C959F',accent:'#0969DA','accent-dim':'rgba(9,105,218,.1)',success:'#1A7F37','success-dim':'rgba(26,127,55,.1)',warning:'#9A6700','warning-dim':'rgba(154,103,0,.1)',danger:'#CF222E','danger-dim':'rgba(207,34,46,.1)',purple:'#8250DF','purple-dim':'rgba(130,80,223,.1)'},
55
+ 'dracula': {bg:'#1E1F29',surface:'#282A36',surface2:'#2E303E',border:'#3A3C4E',border2:'#44475A',text:'#F8F8F2',text2:'#ADB0C0',text3:'#6272A4',accent:'#BD93F9','accent-dim':'rgba(189,147,249,.15)',success:'#50FA7B','success-dim':'rgba(80,250,123,.15)',warning:'#F1FA8C','warning-dim':'rgba(241,250,140,.15)',danger:'#FF5555','danger-dim':'rgba(255,85,85,.15)',purple:'#FF79C6','purple-dim':'rgba(255,121,198,.15)'},
56
+ 'nord': {bg:'#242933',surface:'#2E3440',surface2:'#353C4A',border:'#3B4252',border2:'#434C5E',text:'#ECEFF4',text2:'#A5B1C2',text3:'#697688',accent:'#88C0D0','accent-dim':'rgba(136,192,208,.15)',success:'#A3BE8C','success-dim':'rgba(163,190,140,.15)',warning:'#EBCB8B','warning-dim':'rgba(235,203,139,.15)',danger:'#BF616A','danger-dim':'rgba(191,97,106,.15)',purple:'#B48EAD','purple-dim':'rgba(180,142,173,.15)'},
57
+ 'solarized-dark': {bg:'#002129',surface:'#002B36',surface2:'#073642',border:'#0A4050',border2:'#1A5060',text:'#93A1A1',text2:'#839496',text3:'#586E75',accent:'#268BD2','accent-dim':'rgba(38,139,210,.15)',success:'#859900','success-dim':'rgba(133,153,0,.15)',warning:'#B58900','warning-dim':'rgba(181,137,0,.15)',danger:'#DC322F','danger-dim':'rgba(220,50,47,.15)',purple:'#6C71C4','purple-dim':'rgba(108,113,196,.15)'},
58
+ 'solarized-light': {bg:'#FDF6E3',surface:'#FFFFFF',surface2:'#EEE8D5',border:'#DDD6C1',border2:'#C9C2AD',text:'#586E75',text2:'#657B83',text3:'#93A1A1',accent:'#268BD2','accent-dim':'rgba(38,139,210,.1)',success:'#859900','success-dim':'rgba(133,153,0,.1)',warning:'#B58900','warning-dim':'rgba(181,137,0,.1)',danger:'#DC322F','danger-dim':'rgba(220,50,47,.1)',purple:'#6C71C4','purple-dim':'rgba(108,113,196,.1)'},
59
+ 'one-dark': {bg:'#1E2127',surface:'#282C34',surface2:'#2C313C',border:'#3A3F4B',border2:'#4B5263',text:'#ABB2BF',text2:'#7F848E',text3:'#5C6370',accent:'#61AFEF','accent-dim':'rgba(97,175,239,.15)',success:'#98C379','success-dim':'rgba(152,195,121,.15)',warning:'#E5C07B','warning-dim':'rgba(229,192,123,.15)',danger:'#E06C75','danger-dim':'rgba(224,108,117,.15)',purple:'#C678DD','purple-dim':'rgba(198,120,221,.15)'},
60
+ 'gruvbox-dark': {bg:'#1D2021',surface:'#282828',surface2:'#32302F',border:'#3C3836',border2:'#504945',text:'#EBDBB2',text2:'#A89984',text3:'#7C6F64',accent:'#FE8019','accent-dim':'rgba(254,128,25,.15)',success:'#B8BB26','success-dim':'rgba(184,187,38,.15)',warning:'#FABD2F','warning-dim':'rgba(250,189,47,.15)',danger:'#FB4934','danger-dim':'rgba(251,73,52,.15)',purple:'#D3869B','purple-dim':'rgba(211,134,155,.15)'},
61
+ };
62
+
63
+ function generateThemeCss(themeId) {
64
+ const vars = THEME_VARS[themeId];
65
+ if (!vars) return '';
66
+ const name = THEME_NAMES[themeId] || themeId;
67
+ let css = '/* mdboard theme: ' + name + ' */\n:root {\n';
68
+ for (const key in vars) {
69
+ if (vars.hasOwnProperty(key)) {
70
+ css += ' --' + key + ': ' + vars[key] + ';\n';
71
+ }
72
+ }
73
+ css += '}\n';
74
+ return css;
75
+ }
76
+
77
+ function handleTheme(args) {
78
+ const themeName = args.filter(a => !a.startsWith('--'))[0];
79
+ const isProject = args.includes('--project');
80
+
81
+ if (!themeName) {
82
+ listThemes();
83
+ return;
84
+ }
85
+
86
+ if (!THEME_IDS.includes(themeName)) {
87
+ console.error('\n Error: Unknown theme "' + themeName + '"');
88
+ console.log(' Run "mdboard theme" to see available themes.\n');
89
+ process.exit(1);
90
+ }
91
+
92
+ const configPath = isProject
93
+ ? path.join(process.cwd(), 'project', 'mdboard.json')
94
+ : path.join(os.homedir(), '.config', 'mdboard', 'mdboard.json');
95
+
96
+ const dir = path.dirname(configPath);
97
+ if (!fs.existsSync(dir)) {
98
+ fs.mkdirSync(dir, { recursive: true });
99
+ }
100
+
101
+ let config = {};
102
+ try {
103
+ config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
104
+ } catch {
105
+ // file doesn't exist or is invalid, start fresh
106
+ }
107
+
108
+ config.theme = themeName;
109
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
110
+
111
+ // Also write CSS file
112
+ const css = generateThemeCss(themeName);
113
+ const cssPath = isProject
114
+ ? path.join(process.cwd(), 'project', 'mdboard.css')
115
+ : path.join(os.homedir(), '.config', 'mdboard', 'mdboard.css');
116
+ fs.writeFileSync(cssPath, css, 'utf-8');
117
+
118
+ const scope = isProject ? 'project' : 'global';
119
+ console.log('\n Theme set to "' + THEME_NAMES[themeName] + '" (' + scope + ')');
120
+ console.log(' Config: ' + configPath);
121
+ console.log(' CSS: ' + cssPath + '\n');
122
+ }
123
+
124
+ function listThemes() {
125
+ const dark = THEME_IDS.filter(id => id.includes('dark') || ['catppuccin-mocha', 'tokyo-night', 'tokyo-night-storm', 'dracula', 'nord', 'one-dark', 'gruvbox-dark'].includes(id));
126
+ const light = THEME_IDS.filter(id => !dark.includes(id));
127
+
128
+ console.log('\n Available themes:\n');
129
+ console.log(' Dark:');
130
+ dark.forEach(function(id) {
131
+ console.log(' ' + id.padEnd(24) + THEME_NAMES[id]);
132
+ });
133
+ console.log('\n Light:');
134
+ light.forEach(function(id) {
135
+ console.log(' ' + id.padEnd(24) + THEME_NAMES[id]);
136
+ });
137
+ console.log('\n Usage:');
138
+ console.log(' mdboard theme <name> Set theme globally');
139
+ console.log(' mdboard theme <name> --project Set theme for current project\n');
140
+ }
141
+
142
+ module.exports = { handleTheme };
@@ -0,0 +1,266 @@
1
+ /* ══════════════════════════════════════════════════════════════
2
+ RENDER
3
+ ══════════════════════════════════════════════════════════════ */
4
+ function renderAll() {
5
+ renderHeader(); renderBoardFilters(); renderBoard(); renderTableControls(); renderTableBody(); renderMsFilters(); renderMilestones(); renderMetrics();
6
+ // Render overview if active
7
+ var overviewView = document.getElementById('view-overview');
8
+ if (overviewView && overviewView.classList.contains('active')) {
9
+ renderOverview();
10
+ }
11
+ }
12
+
13
+ function renderHeader() {
14
+ renderSidebarLogo();
15
+
16
+ var mw = document.getElementById('h-milestone-wrap');
17
+ var am = D.milestones.find(function(m) { return m.status === 'active'; });
18
+ if (am) {
19
+ mw.style.display = '';
20
+ document.getElementById('h-milestone-name').textContent = am.title || am.id || '';
21
+ document.getElementById('h-milestone-progress').style.width = (am.progress || 0) + '%';
22
+ } else { mw.style.display = 'none'; }
23
+
24
+ var sw = document.getElementById('h-sprint-wrap');
25
+ var as = D.sprints.find(function(s) { return s.status === 'active'; });
26
+ if (as) {
27
+ sw.style.display = '';
28
+ document.getElementById('h-sprint-name').textContent = as.goal || as.id || '';
29
+ var dr = daysRemaining(as.end_date);
30
+ document.getElementById('h-sprint-days').textContent = dr !== null ? (dr >= 0 ? dr + ' days left' : Math.abs(dr) + ' days over') : '';
31
+ } else { sw.style.display = 'none'; }
32
+
33
+ var taskPlural = ENTITY_NAMES.task ? ENTITY_NAMES.task.plural : 'Tasks';
34
+ var total = D.tasks.length;
35
+ var done = D.tasks.filter(function(f) { return f.status === COMPLETED_STATUS; }).length;
36
+ var inProgStatus = (D.config && D.config.statuses && D.config.statuses.task) ?
37
+ ((D.config.statuses.task.find(function(s) { return s.icon === 'half-circle'; }) || {}).key || 'in-progress') : 'in-progress';
38
+ var inProg = D.tasks.filter(function(f) { return f.status === inProgStatus; }).length;
39
+ var vel = D.health && D.health.velocity != null ? D.health.velocity : (total ? Math.round(done / total * 100) : 0);
40
+ document.getElementById('h-stats').innerHTML =
41
+ '<div class="stat"><span class="stat-val">' + total + '</span><span class="stat-label">' + escHtml(taskPlural) + '</span></div>' +
42
+ '<div class="stat"><span class="stat-val" style="color:var(--success)">' + done + '</span><span class="stat-label">Done</span></div>' +
43
+ '<div class="stat"><span class="stat-val" style="color:var(--warning)">' + inProg + '</span><span class="stat-label">In Progress</span></div>' +
44
+ '<div class="stat"><span class="stat-val" style="color:var(--accent)">' + vel + '%</span><span class="stat-label">Velocity</span></div>';
45
+ }
46
+
47
+ /* ══════════════════════════════════════════════════════════════
48
+ NAVIGATION
49
+ ══════════════════════════════════════════════════════════════ */
50
+ function switchView(name) {
51
+ document.querySelectorAll('.view').forEach(function(v) { v.classList.remove('active'); });
52
+ document.querySelectorAll('#sidebar-nav a[data-view]').forEach(function(a) { a.classList.remove('active'); });
53
+ var t = document.getElementById('view-' + name);
54
+ if (t) t.classList.add('active');
55
+ var l = document.querySelector('#sidebar-nav a[data-view="' + name + '"]');
56
+ if (l) l.classList.add('active');
57
+ // Persist view selection
58
+ try { localStorage.setItem('mdboard-view', name); } catch(e) {}
59
+ // Trigger overview rendering when switching to it
60
+ if (name === 'overview' && D.loaded) {
61
+ renderOverview();
62
+ }
63
+ // Trigger notes rendering when switching to it
64
+ if (name === 'notes' && D.loaded) {
65
+ renderNotes();
66
+ }
67
+ }
68
+
69
+ document.getElementById('sidebar-nav').addEventListener('click', function(e) {
70
+ var a = e.target.closest('a[data-view]');
71
+ if (!a) return;
72
+ e.preventDefault();
73
+ switchView(a.dataset.view);
74
+ window.location.hash = a.dataset.view;
75
+ });
76
+
77
+ function handleHash() {
78
+ var hash = window.location.hash.replace('#', '');
79
+ if (!hash) {
80
+ try { hash = localStorage.getItem('mdboard-view') || ''; } catch(e) {}
81
+ }
82
+ hash = hash || 'board';
83
+ var valid = ['board','table','milestones','metrics','overview','notes'];
84
+ switchView(valid.indexOf(hash) !== -1 ? hash : 'board');
85
+ }
86
+ window.addEventListener('hashchange', handleHash);
87
+
88
+ /* ══════════════════════════════════════════════════════════════
89
+ SETTINGS PANEL
90
+ ══════════════════════════════════════════════════════════════ */
91
+ var _settingsState = { selectedTheme: null, originalTheme: null };
92
+
93
+ function openSettings() {
94
+ var active = getActiveTheme();
95
+ _settingsState.originalTheme = active;
96
+ _settingsState.selectedTheme = null;
97
+ document.getElementById('settings-overlay').classList.add('open');
98
+ document.getElementById('settings-panel').classList.add('open');
99
+ var toggle = document.getElementById('theme-default-toggle');
100
+ if (toggle) toggle.checked = false;
101
+ renderThemeGrid();
102
+ }
103
+
104
+ function closeSettings() {
105
+ if (_settingsState.selectedTheme) {
106
+ revertThemePreview();
107
+ _settingsState.selectedTheme = null;
108
+ }
109
+ document.getElementById('settings-overlay').classList.remove('open');
110
+ document.getElementById('settings-panel').classList.remove('open');
111
+ }
112
+
113
+ function renderThemeGrid() {
114
+ var grid = document.getElementById('theme-grid');
115
+ var info = document.getElementById('settings-theme-info');
116
+ var active = getActiveTheme();
117
+ var selected = _settingsState.selectedTheme;
118
+
119
+ if (info) {
120
+ var displayId = selected || active;
121
+ var displayTheme = THEMES[displayId];
122
+ info.textContent = 'Active: ' + (THEMES[active] ? THEMES[active].name : active) +
123
+ (selected && selected !== active ? ' → Preview: ' + (displayTheme ? displayTheme.name : selected) : '');
124
+ }
125
+
126
+ var html = '';
127
+ THEME_LIST.forEach(function(t) {
128
+ var theme = THEMES[t.id];
129
+ var v = theme.vars;
130
+ var isSelected = selected ? t.id === selected : t.id === active;
131
+ var cls = isSelected ? ' active' : '';
132
+ html += '<div class="theme-card' + cls + '" data-theme="' + t.id + '">' +
133
+ '<div class="theme-card-preview">' +
134
+ '<span style="background:' + v.bg + '"></span>' +
135
+ '<span style="background:' + v.surface + '"></span>' +
136
+ '<span style="background:' + v.accent + '"></span>' +
137
+ '<span style="background:' + v.success + '"></span>' +
138
+ '<span style="background:' + v.warning + '"></span>' +
139
+ '</div>' +
140
+ '<div class="theme-card-name">' + escHtml(theme.name) + '</div>' +
141
+ '<div class="theme-card-type">' + theme.type + '</div>' +
142
+ '</div>';
143
+ });
144
+ grid.innerHTML = html;
145
+ }
146
+
147
+ function saveTheme() {
148
+ var themeId = _settingsState.selectedTheme;
149
+ if (!themeId) {
150
+ closeSettings();
151
+ return;
152
+ }
153
+ var toggle = document.getElementById('theme-default-toggle');
154
+ var setDefault = toggle ? toggle.checked : false;
155
+ var css = generateThemeCss(themeId);
156
+
157
+ fetch('/api/theme', {
158
+ method: 'POST',
159
+ headers: { 'Content-Type': 'application/json' },
160
+ body: JSON.stringify({ themeId: themeId, css: css, setDefault: setDefault })
161
+ }).then(function(r) { return r.json(); }).then(function(data) {
162
+ if (data && data.ok) {
163
+ revertThemePreview();
164
+ reloadCustomCss();
165
+ D.config.theme = themeId;
166
+ _settingsState.selectedTheme = null;
167
+ showToast('Theme saved: ' + (THEMES[themeId] ? THEMES[themeId].name : themeId), 'success');
168
+ closeSettings();
169
+ } else {
170
+ showToast('Error: ' + (data.error || 'Unknown'), 'error');
171
+ }
172
+ }).catch(function(e) {
173
+ showToast('Error: ' + e.message, 'error');
174
+ });
175
+ }
176
+
177
+ document.getElementById('settings-toggle').addEventListener('click', function(e) {
178
+ e.preventDefault();
179
+ openSettings();
180
+ });
181
+
182
+ document.getElementById('settings-close').addEventListener('click', closeSettings);
183
+ document.getElementById('settings-overlay').addEventListener('click', closeSettings);
184
+
185
+ document.getElementById('theme-grid').addEventListener('click', function(e) {
186
+ var card = e.target.closest('.theme-card');
187
+ if (!card) return;
188
+ var themeId = card.dataset.theme;
189
+ _settingsState.selectedTheme = themeId;
190
+ applyTheme(themeId);
191
+ renderThemeGrid();
192
+ });
193
+
194
+ document.getElementById('settings-save').addEventListener('click', saveTheme);
195
+
196
+ /* ══════════════════════════════════════════════════════════════
197
+ INIT
198
+ ══════════════════════════════════════════════════════════════ */
199
+ (async function() {
200
+ handleHash();
201
+ await loadAll();
202
+ initFromConfig();
203
+
204
+ // Sidebar logo always shows project name
205
+ renderSidebarLogo();
206
+
207
+ // Workspace detection: build source rail + restore last source
208
+ if (hasWorkspace()) {
209
+ // Create overview tab (hidden by default)
210
+ var overviewTab = document.querySelector('#sidebar-nav a[data-view="overview"]');
211
+ if (!overviewTab) {
212
+ var nav = document.getElementById('sidebar-nav');
213
+ var metricsLink = document.querySelector('#sidebar-nav a[data-view="metrics"]');
214
+ var overviewLink = document.createElement('a');
215
+ overviewLink.href = '#overview';
216
+ overviewLink.dataset.view = 'overview';
217
+ overviewLink.style.display = 'none';
218
+ 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>';
219
+ if (metricsLink && metricsLink.nextSibling) {
220
+ nav.insertBefore(overviewLink, metricsLink.nextSibling);
221
+ } else {
222
+ nav.appendChild(overviewLink);
223
+ }
224
+ }
225
+
226
+ // Restore last source or default to overview
227
+ var savedSource = null;
228
+ try { savedSource = localStorage.getItem('mdboard-source'); } catch(e) {}
229
+ var validSource = savedSource && (savedSource === 'overview' || D.config.workspace.sources.some(function(s) { return s.name === savedSource; }));
230
+ var initialSource = validSource ? savedSource : 'overview';
231
+
232
+ buildSourceRail();
233
+ // switchSource skips if same as current, so set activeSource first then trigger
234
+ D.activeSource = null;
235
+ switchSource(initialSource);
236
+ // Wait for switchSource data reload before continuing
237
+ await loadAll();
238
+ initFromConfig();
239
+ }
240
+
241
+ renderAll();
242
+ connectSSE();
243
+
244
+ // Auto-open history modal if no project
245
+ checkAutoOpenHistory();
246
+
247
+ // Keyboard shortcut: Ctrl/Cmd+K to open history modal
248
+ document.addEventListener('keydown', function(e) {
249
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
250
+ e.preventDefault();
251
+ openHistoryModal();
252
+ }
253
+ });
254
+
255
+ // Load AI suggestions for autocomplete
256
+ fetchJson('/api/ai-suggestions').then(function(data) {
257
+ D.aiSuggestions = data || { skills: [], agents: [], mcps: [], commands: [], context: [] };
258
+ });
259
+
260
+ // Load overview links in background for reverse link display
261
+ if (hasWorkspace()) {
262
+ fetchJson('/api/overview/links').then(function(data) {
263
+ D.overviewLinks = data;
264
+ });
265
+ }
266
+ })();