yatt-md 0.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.
@@ -0,0 +1,3187 @@
1
+ #!/usr/bin/env node
2
+
3
+ // cli/serve.ts
4
+ import * as http from "node:http";
5
+ import * as fs from "node:fs";
6
+ import * as path from "node:path";
7
+ import { execFile } from "node:child_process";
8
+
9
+ // src/lexer.ts
10
+ var HEADER_KEYS = /* @__PURE__ */ new Set([
11
+ "title",
12
+ "owner",
13
+ "start",
14
+ "schedule",
15
+ "timezone",
16
+ "week-start"
17
+ ]);
18
+ function tokenize(source) {
19
+ const lines = source.split(/\r?\n/);
20
+ const tokens = [];
21
+ let headerMode = true;
22
+ for (let i = 0; i < lines.length; i++) {
23
+ const lineNum = i + 1;
24
+ const raw = lines[i];
25
+ const trimmed = raw.trim();
26
+ if (trimmed === "") {
27
+ tokens.push({ type: "blank", raw, line: lineNum });
28
+ continue;
29
+ }
30
+ if (trimmed.startsWith("//")) {
31
+ tokens.push({ type: "comment", raw, line: lineNum, content: trimmed.slice(2).trim() });
32
+ continue;
33
+ }
34
+ if (trimmed.startsWith("##")) {
35
+ headerMode = false;
36
+ tokens.push({ type: "section", raw, line: lineNum, level: 2, content: trimmed.slice(2).trim() });
37
+ continue;
38
+ }
39
+ if (trimmed.startsWith("#")) {
40
+ headerMode = false;
41
+ tokens.push({ type: "section", raw, line: lineNum, level: 1, content: trimmed.slice(1).trim() });
42
+ continue;
43
+ }
44
+ if (/^parallel\s*:/i.test(trimmed)) {
45
+ headerMode = false;
46
+ const rest = trimmed.replace(/^parallel\s*:\s*/i, "");
47
+ tokens.push({ type: "parallel-open", raw, line: lineNum, content: rest });
48
+ continue;
49
+ }
50
+ if (/^end\s*:/i.test(trimmed)) {
51
+ headerMode = false;
52
+ const rest = trimmed.replace(/^end\s*:\s*/i, "");
53
+ tokens.push({ type: "parallel-close", raw, line: lineNum, content: rest });
54
+ continue;
55
+ }
56
+ if (trimmed.startsWith(">>")) {
57
+ headerMode = false;
58
+ tokens.push({ type: "milestone", raw, line: lineNum, content: trimmed.slice(2).trim() });
59
+ continue;
60
+ }
61
+ if (trimmed.startsWith("...")) {
62
+ headerMode = false;
63
+ tokens.push({ type: "subtask", raw, line: lineNum, depth: 3, content: trimmed.slice(3).trim() });
64
+ continue;
65
+ }
66
+ if (trimmed.startsWith("..") && !trimmed.startsWith("...")) {
67
+ headerMode = false;
68
+ tokens.push({ type: "subtask", raw, line: lineNum, depth: 2, content: trimmed.slice(2).trim() });
69
+ continue;
70
+ }
71
+ if (trimmed.startsWith(".") && !trimmed.startsWith("..")) {
72
+ headerMode = false;
73
+ tokens.push({ type: "subtask", raw, line: lineNum, depth: 1, content: trimmed.slice(1).trim() });
74
+ continue;
75
+ }
76
+ if (trimmed.startsWith("[")) {
77
+ headerMode = false;
78
+ tokens.push({ type: "task", raw, line: lineNum, content: trimmed });
79
+ continue;
80
+ }
81
+ if (headerMode) {
82
+ const headerMatch = trimmed.match(/^([\w-]+)\s*:\s*(.*)$/);
83
+ if (headerMatch && HEADER_KEYS.has(headerMatch[1].toLowerCase())) {
84
+ tokens.push({
85
+ type: "header-field",
86
+ raw,
87
+ line: lineNum,
88
+ key: headerMatch[1].toLowerCase(),
89
+ value: headerMatch[2].trim()
90
+ });
91
+ continue;
92
+ }
93
+ }
94
+ headerMode = false;
95
+ let plainContent = trimmed;
96
+ const listMarkerMatch = trimmed.match(/^(?:[-*]\s+|\d+[.)]\s+)/);
97
+ if (listMarkerMatch) {
98
+ plainContent = trimmed.slice(listMarkerMatch[0].length);
99
+ }
100
+ tokens.push({ type: "task", raw, line: lineNum, content: plainContent });
101
+ }
102
+ return tokens;
103
+ }
104
+
105
+ // src/parser.ts
106
+ var SIGIL_TO_STATUS = {
107
+ " ": "new",
108
+ "~": "active",
109
+ "x": "done",
110
+ "!": "blocked",
111
+ "?": "at-risk",
112
+ ">": "deferred",
113
+ "_": "cancelled",
114
+ "-": "cancelled",
115
+ "=": "review",
116
+ "o": "paused"
117
+ };
118
+ var WORD_TO_STATUS = {
119
+ new: "new",
120
+ active: "active",
121
+ done: "done",
122
+ blocked: "blocked",
123
+ "at-risk": "at-risk",
124
+ deferred: "deferred",
125
+ cancelled: "cancelled",
126
+ review: "review",
127
+ paused: "paused"
128
+ };
129
+ var RE_DURATION = /^(\d+(?:\.\d+)?)(h|bd|d|w|m|q)$/;
130
+ var RE_PRIORITY = /^!(high|critical|low|normal)$/;
131
+ var RE_PROGRESS = /^%(\d+)$/;
132
+ var RE_ID = /^id:([\w-]+)$/;
133
+ var RE_HASH_ID = /^#([\w-]+)$/;
134
+ var RE_AFTER = /^after:([\w|,-]+)$/;
135
+ var RE_MODIFIER = /^\+[\w-]+(:\S+)?$/;
136
+ var RE_START_DATE = /^>(\d{4}-\d{2}-\d{2})$/;
137
+ var RE_DUE_DATE = /^<(\d{4}-\d{2}-\d{2})$/;
138
+ var RE_RECURRENCE = /^\*(daily|weekday|weekly|biweekly|monthly|quarterly|yearly)$/;
139
+ var RE_EXTERNAL_REF = /^\$[\w-]+$/;
140
+ var MODIFIER_KEYWORDS = /* @__PURE__ */ new Set([
141
+ "deadline",
142
+ "fixed",
143
+ "external",
144
+ "waiting",
145
+ "at-risk",
146
+ "blocked",
147
+ "critical",
148
+ "tentative",
149
+ "recurring",
150
+ "milestone",
151
+ "delayed",
152
+ "hard-block"
153
+ ]);
154
+ function parseStatus(content) {
155
+ const wordMatch = content.match(/^\[([\w-]+)\]\s*/);
156
+ if (wordMatch) {
157
+ const word = wordMatch[1].toLowerCase();
158
+ if (WORD_TO_STATUS[word]) {
159
+ return { status: WORD_TO_STATUS[word], rest: content.slice(wordMatch[0].length) };
160
+ }
161
+ }
162
+ const sigilMatch = content.match(/^\[(.)\]\s*/);
163
+ if (sigilMatch) {
164
+ const sigil = sigilMatch[1];
165
+ if (SIGIL_TO_STATUS[sigil] !== void 0) {
166
+ return { status: SIGIL_TO_STATUS[sigil], rest: content.slice(sigilMatch[0].length) };
167
+ }
168
+ }
169
+ return null;
170
+ }
171
+ function parseFields(segments) {
172
+ const result = { assignees: [], tags: [], after: [], modifiers: [] };
173
+ for (const seg of segments) {
174
+ const s = seg.trim();
175
+ if (!s) continue;
176
+ const durMatch = s.match(RE_DURATION);
177
+ if (durMatch && !result.duration) {
178
+ result.duration = { value: parseFloat(durMatch[1]), unit: durMatch[2] };
179
+ continue;
180
+ }
181
+ const startMatch = s.match(RE_START_DATE);
182
+ if (startMatch) {
183
+ result.startDate = startMatch[1];
184
+ continue;
185
+ }
186
+ const dueMatch = s.match(RE_DUE_DATE);
187
+ if (dueMatch) {
188
+ result.dueDate = dueMatch[1];
189
+ continue;
190
+ }
191
+ const idMatch = s.match(RE_ID);
192
+ if (idMatch) {
193
+ result.id = idMatch[1];
194
+ continue;
195
+ }
196
+ const afterMatch = s.match(RE_AFTER);
197
+ if (afterMatch) {
198
+ const raw = afterMatch[1];
199
+ if (raw.includes("|")) {
200
+ result.after.push({ ids: raw.split("|").filter(Boolean), logic: "or" });
201
+ } else {
202
+ result.after.push({ ids: raw.split(",").filter(Boolean), logic: "and" });
203
+ }
204
+ continue;
205
+ }
206
+ const prioMatch = s.match(RE_PRIORITY);
207
+ if (prioMatch) {
208
+ result.priority = prioMatch[1];
209
+ continue;
210
+ }
211
+ const progressMatch = s.match(RE_PROGRESS);
212
+ if (progressMatch) {
213
+ result.progress = Math.min(100, Math.max(0, parseInt(progressMatch[1], 10)));
214
+ continue;
215
+ }
216
+ const recurMatch = s.match(RE_RECURRENCE);
217
+ if (recurMatch) {
218
+ result.recurrence = recurMatch[1];
219
+ continue;
220
+ }
221
+ const shiftMatch = s.match(/^(delayed|blocked)\s+(\S+)$/);
222
+ if (shiftMatch) {
223
+ result.modifiers.push(shiftMatch[1] + ":" + shiftMatch[2]);
224
+ continue;
225
+ }
226
+ if (RE_MODIFIER.test(s)) {
227
+ result.modifiers.push(s.slice(1));
228
+ continue;
229
+ }
230
+ const bareModMatch = s.match(/^([\w-]+)(:\S+)?$/);
231
+ if (bareModMatch && MODIFIER_KEYWORDS.has(bareModMatch[1])) {
232
+ result.modifiers.push(s);
233
+ continue;
234
+ }
235
+ if (RE_EXTERNAL_REF.test(s)) {
236
+ result.externalRef = s;
237
+ continue;
238
+ }
239
+ const assigneeMatches = s.match(/(@[\w-]+)/g);
240
+ if (assigneeMatches && s.replace(/(@[\w-]+)/g, "").trim() === "") {
241
+ result.assignees.push(...assigneeMatches.map((a) => a.slice(1)));
242
+ continue;
243
+ }
244
+ const hashIdMatch = s.match(RE_HASH_ID);
245
+ if (hashIdMatch) {
246
+ result.id = hashIdMatch[1];
247
+ continue;
248
+ }
249
+ }
250
+ return result;
251
+ }
252
+ function splitPipeFields(content) {
253
+ const raw = content.split("|");
254
+ const merged = [];
255
+ for (let i = 0; i < raw.length; i++) {
256
+ const seg = raw[i].trim();
257
+ if (merged.length > 0 && /^after:/.test(merged[merged.length - 1].trim()) && /^[\w-]+$/.test(seg)) {
258
+ merged[merged.length - 1] += "|" + seg;
259
+ } else {
260
+ merged.push(raw[i]);
261
+ }
262
+ }
263
+ return merged;
264
+ }
265
+ function parseNameAndFields(content) {
266
+ const parts = splitPipeFields(content);
267
+ const name = (parts[0] ?? "").trim();
268
+ const fields = parseFields(parts.slice(1));
269
+ return { name, fields };
270
+ }
271
+ function buildTask(content, line2, depth = 0) {
272
+ const statusResult = parseStatus(content);
273
+ const status = statusResult?.status ?? "new";
274
+ const rest = statusResult?.rest ?? content;
275
+ const { name, fields } = parseNameAndFields(rest);
276
+ const task = {
277
+ type: "task",
278
+ status,
279
+ name,
280
+ assignees: fields.assignees,
281
+ tags: fields.tags,
282
+ after: fields.after,
283
+ modifiers: fields.modifiers,
284
+ subtasks: [],
285
+ line: line2
286
+ };
287
+ if (fields.id) task.id = fields.id;
288
+ if (fields.priority) task.priority = fields.priority;
289
+ if (fields.progress !== void 0) task.progress = fields.progress;
290
+ if (fields.duration) task.duration = fields.duration;
291
+ if (fields.startDate) task.startDate = fields.startDate;
292
+ if (fields.dueDate) task.dueDate = fields.dueDate;
293
+ if (fields.recurrence) task.recurrence = fields.recurrence;
294
+ if (fields.externalRef) task.externalRef = fields.externalRef;
295
+ return task;
296
+ }
297
+ function buildMilestone(content, line2) {
298
+ const { name, fields } = parseNameAndFields(content);
299
+ const ms = {
300
+ type: "milestone",
301
+ name,
302
+ after: fields.after,
303
+ modifiers: fields.modifiers,
304
+ line: line2
305
+ };
306
+ if (fields.id) ms.id = fields.id;
307
+ if (fields.startDate) ms.date = fields.startDate;
308
+ if (fields.dueDate) ms.date = fields.dueDate;
309
+ return ms;
310
+ }
311
+ function parse(source) {
312
+ const tokens = tokenize(source);
313
+ const errors = [];
314
+ const header = {};
315
+ const items = [];
316
+ const idMap = /* @__PURE__ */ new Map();
317
+ function registerId(id, item, line2) {
318
+ if (idMap.has(id)) {
319
+ errors.push({ message: `Duplicate ID: "${id}"`, line: line2, severity: "error" });
320
+ } else {
321
+ idMap.set(id, item);
322
+ }
323
+ }
324
+ let i = 0;
325
+ while (i < tokens.length && tokens[i].type === "header-field") {
326
+ const tok = tokens[i++];
327
+ switch (tok.key) {
328
+ case "title":
329
+ header.title = tok.value;
330
+ break;
331
+ case "owner":
332
+ header.owner = tok.value;
333
+ break;
334
+ case "start":
335
+ header.start = tok.value;
336
+ break;
337
+ case "schedule":
338
+ if (tok.value === "business-days" || tok.value === "calendar-days") {
339
+ header.schedule = tok.value;
340
+ }
341
+ break;
342
+ case "timezone":
343
+ header.timezone = tok.value;
344
+ break;
345
+ case "week-start":
346
+ if (tok.value === "mon" || tok.value === "sun") header.weekStart = tok.value;
347
+ break;
348
+ }
349
+ }
350
+ const parallelStack = [];
351
+ function currentContainer() {
352
+ if (parallelStack.length > 0) {
353
+ return parallelStack[parallelStack.length - 1];
354
+ }
355
+ return { items };
356
+ }
357
+ const taskStack = [null, null, null, null];
358
+ function resetTaskStack() {
359
+ taskStack[0] = taskStack[1] = taskStack[2] = taskStack[3] = null;
360
+ }
361
+ while (i < tokens.length) {
362
+ const tok = tokens[i++];
363
+ if (tok.type === "blank" || tok.type === "header-field") continue;
364
+ if (tok.type === "comment") {
365
+ const comment = { type: "comment", text: tok.content ?? "", line: tok.line };
366
+ currentContainer().items.push(comment);
367
+ continue;
368
+ }
369
+ if (tok.type === "section") {
370
+ resetTaskStack();
371
+ const section = {
372
+ type: "section",
373
+ title: tok.content ?? "",
374
+ level: tok.level === 2 ? 2 : 1,
375
+ line: tok.line
376
+ };
377
+ currentContainer().items.push(section);
378
+ continue;
379
+ }
380
+ if (tok.type === "parallel-open") {
381
+ resetTaskStack();
382
+ const content = tok.content ?? "";
383
+ const pipeIdx = content.indexOf("|");
384
+ let blockName;
385
+ let blockFields;
386
+ if (pipeIdx !== -1) {
387
+ blockName = content.slice(0, pipeIdx).trim() || void 0;
388
+ blockFields = parseFields(content.slice(pipeIdx + 1).split("|"));
389
+ } else {
390
+ blockName = content.trim() || void 0;
391
+ blockFields = parseFields([]);
392
+ }
393
+ const block = {
394
+ type: "parallel",
395
+ name: blockName,
396
+ id: blockName ?? void 0,
397
+ after: blockFields.after,
398
+ items: [],
399
+ line: tok.line
400
+ };
401
+ if (block.id) registerId(block.id, block, tok.line);
402
+ currentContainer().items.push(block);
403
+ parallelStack.push(block);
404
+ continue;
405
+ }
406
+ if (tok.type === "parallel-close") {
407
+ resetTaskStack();
408
+ if (parallelStack.length === 0) {
409
+ errors.push({ message: "Unexpected end: without matching parallel:", line: tok.line, severity: "error" });
410
+ } else {
411
+ parallelStack.pop();
412
+ }
413
+ continue;
414
+ }
415
+ if (tok.type === "milestone") {
416
+ resetTaskStack();
417
+ const ms = buildMilestone(tok.content ?? "", tok.line);
418
+ if (ms.id) registerId(ms.id, ms, tok.line);
419
+ const descLines = [];
420
+ while (i < tokens.length && tokens[i].type === "comment") {
421
+ descLines.push(tokens[i].content ?? "");
422
+ i++;
423
+ }
424
+ if (descLines.length > 0) ms.description = descLines.join("\n");
425
+ currentContainer().items.push(ms);
426
+ continue;
427
+ }
428
+ if (tok.type === "task") {
429
+ const task = buildTask(tok.content ?? "", tok.line, 0);
430
+ if (task.id) registerId(task.id, task, tok.line);
431
+ const descLines = [];
432
+ while (i < tokens.length && tokens[i].type === "comment") {
433
+ descLines.push(tokens[i].content ?? "");
434
+ i++;
435
+ }
436
+ if (descLines.length > 0) task.description = descLines.join("\n");
437
+ currentContainer().items.push(task);
438
+ taskStack[0] = task;
439
+ taskStack[1] = taskStack[2] = taskStack[3] = null;
440
+ continue;
441
+ }
442
+ if (tok.type === "subtask") {
443
+ const depth = tok.depth ?? 1;
444
+ const task = buildTask(tok.content ?? "", tok.line, depth);
445
+ if (task.id) registerId(task.id, task, tok.line);
446
+ const descLines = [];
447
+ while (i < tokens.length && tokens[i].type === "comment") {
448
+ descLines.push(tokens[i].content ?? "");
449
+ i++;
450
+ }
451
+ if (descLines.length > 0) task.description = descLines.join("\n");
452
+ taskStack[depth] = task;
453
+ for (let d = depth + 1; d <= 3; d++) taskStack[d] = null;
454
+ let attached = false;
455
+ for (let pd = depth - 1; pd >= 0; pd--) {
456
+ if (taskStack[pd]) {
457
+ taskStack[pd].subtasks.push(task);
458
+ attached = true;
459
+ break;
460
+ }
461
+ }
462
+ if (!attached) {
463
+ currentContainer().items.push(task);
464
+ }
465
+ continue;
466
+ }
467
+ }
468
+ if (parallelStack.length > 0) {
469
+ errors.push({
470
+ message: `Unclosed parallel block: "${parallelStack[parallelStack.length - 1].name ?? "(anonymous)"}"`,
471
+ line: parallelStack[parallelStack.length - 1].line,
472
+ severity: "error"
473
+ });
474
+ }
475
+ const doc = { header, items, idMap };
476
+ function resolveItem(item) {
477
+ for (const dep of item.after) {
478
+ for (const id of dep.ids) {
479
+ if (!idMap.has(id)) {
480
+ errors.push({
481
+ message: `Unknown dependency ID: "${id}"`,
482
+ line: item.line,
483
+ severity: "error"
484
+ });
485
+ }
486
+ }
487
+ }
488
+ if (item.type === "task") {
489
+ for (const sub of item.subtasks) resolveItem(sub);
490
+ }
491
+ }
492
+ function resolveBlock(block) {
493
+ for (const dep of block.after) {
494
+ for (const id of dep.ids) {
495
+ if (!idMap.has(id)) {
496
+ errors.push({
497
+ message: `Unknown dependency ID: "${id}" in parallel block`,
498
+ line: block.line,
499
+ severity: "error"
500
+ });
501
+ }
502
+ }
503
+ }
504
+ for (const it of block.items) {
505
+ if (it.type === "task" || it.type === "milestone") resolveItem(it);
506
+ else if (it.type === "parallel") resolveBlock(it);
507
+ }
508
+ }
509
+ for (const item of items) {
510
+ if (item.type === "task" || item.type === "milestone") resolveItem(item);
511
+ else if (item.type === "parallel") resolveBlock(item);
512
+ }
513
+ return { doc, errors };
514
+ }
515
+
516
+ // src/validator.ts
517
+ function validate(doc) {
518
+ const errors = [];
519
+ const allSchedulable = [];
520
+ function collectItems(items) {
521
+ for (const item of items) {
522
+ if (item.type === "task") {
523
+ allSchedulable.push(item);
524
+ collectItems(item.subtasks);
525
+ } else if (item.type === "milestone") {
526
+ allSchedulable.push(item);
527
+ } else if (item.type === "parallel") {
528
+ collectItems(item.items);
529
+ }
530
+ }
531
+ }
532
+ collectItems(doc.items);
533
+ for (const item of allSchedulable) {
534
+ for (const dep of item.after) {
535
+ for (const id of dep.ids) {
536
+ if (!doc.idMap.has(id)) {
537
+ errors.push({
538
+ message: `Reference to unknown ID "${id}"`,
539
+ line: item.line,
540
+ severity: "error"
541
+ });
542
+ }
543
+ }
544
+ }
545
+ }
546
+ for (const item of allSchedulable) {
547
+ if (item.type === "task") {
548
+ if (item.progress === 100 && item.status !== "done") {
549
+ errors.push({
550
+ message: `Task "${item.name}" has 100% progress but status is not "done"`,
551
+ line: item.line,
552
+ severity: "warning"
553
+ });
554
+ }
555
+ if (item.status === "done" && item.progress !== void 0 && item.progress < 100) {
556
+ errors.push({
557
+ message: `Task "${item.name}" has status "done" but progress is ${item.progress}%`,
558
+ line: item.line,
559
+ severity: "warning"
560
+ });
561
+ }
562
+ }
563
+ }
564
+ for (const item of allSchedulable) {
565
+ if (item.type === "task") {
566
+ if ((item.status === "deferred" || item.status === "cancelled") && item.after.length > 0) {
567
+ errors.push({
568
+ message: `Task "${item.name}" is ${item.status} but has dependency constraints`,
569
+ line: item.line,
570
+ severity: "warning"
571
+ });
572
+ }
573
+ }
574
+ }
575
+ const inDegree = /* @__PURE__ */ new Map();
576
+ const dependents = /* @__PURE__ */ new Map();
577
+ for (const [id] of doc.idMap) {
578
+ inDegree.set(id, 0);
579
+ dependents.set(id, []);
580
+ }
581
+ function addDepsForItem(item) {
582
+ if (!item.id) return;
583
+ for (const dep of item.after) {
584
+ for (const depId of dep.ids) {
585
+ if (!doc.idMap.has(depId)) continue;
586
+ dependents.get(depId)?.push(item.id);
587
+ inDegree.set(item.id, (inDegree.get(item.id) ?? 0) + 1);
588
+ }
589
+ }
590
+ }
591
+ for (const item of allSchedulable) {
592
+ addDepsForItem(item);
593
+ }
594
+ const queue = [];
595
+ for (const [id, deg] of inDegree) {
596
+ if (deg === 0) queue.push(id);
597
+ }
598
+ let processed = 0;
599
+ while (queue.length > 0) {
600
+ const current = queue.shift();
601
+ processed++;
602
+ for (const dependent of dependents.get(current) ?? []) {
603
+ const newDeg = (inDegree.get(dependent) ?? 1) - 1;
604
+ inDegree.set(dependent, newDeg);
605
+ if (newDeg === 0) queue.push(dependent);
606
+ }
607
+ }
608
+ if (processed < inDegree.size) {
609
+ const cycleIds = [...inDegree.entries()].filter(([, deg]) => deg > 0).map(([id]) => id);
610
+ errors.push({
611
+ message: `Dependency cycle detected among: ${cycleIds.join(", ")}`,
612
+ line: 0,
613
+ severity: "error"
614
+ });
615
+ }
616
+ return errors;
617
+ }
618
+
619
+ // src/scheduler.ts
620
+ function parseModifierDuration(modifier) {
621
+ const match = modifier.match(/^[\w-]+:(\d+(?:\.\d+)?)(h|bd|d|w|m|q)$/);
622
+ if (!match) return null;
623
+ return { value: parseFloat(match[1]), unit: match[2] };
624
+ }
625
+ function addDays(date, days) {
626
+ const d = new Date(date);
627
+ d.setUTCDate(d.getUTCDate() + Math.round(days));
628
+ return d;
629
+ }
630
+ function isWeekend(date, weekStart = "mon") {
631
+ const dow = date.getUTCDay();
632
+ if (weekStart === "sun") {
633
+ return dow === 5 || dow === 6;
634
+ }
635
+ return dow === 0 || dow === 6;
636
+ }
637
+ function addBusinessDays(date, days, weekStart = "mon") {
638
+ let d = new Date(date);
639
+ let remaining = Math.round(days);
640
+ const sign = remaining >= 0 ? 1 : -1;
641
+ remaining = Math.abs(remaining);
642
+ while (remaining > 0) {
643
+ d = addDays(d, sign);
644
+ if (!isWeekend(d, weekStart)) {
645
+ remaining--;
646
+ }
647
+ }
648
+ return d;
649
+ }
650
+ function addDuration(start, duration, useBusinessDays, weekStart = "mon") {
651
+ const { value, unit } = duration;
652
+ switch (unit) {
653
+ case "h": {
654
+ const ms = value * 60 * 60 * 1e3;
655
+ return new Date(start.getTime() + ms);
656
+ }
657
+ case "d":
658
+ return addDays(start, value);
659
+ case "bd":
660
+ return addBusinessDays(start, value, weekStart);
661
+ case "w":
662
+ return addDays(start, value * 7);
663
+ case "m": {
664
+ const d = new Date(start);
665
+ d.setUTCMonth(d.getUTCMonth() + value);
666
+ return d;
667
+ }
668
+ case "q": {
669
+ const d = new Date(start);
670
+ d.setUTCMonth(d.getUTCMonth() + value * 3);
671
+ return d;
672
+ }
673
+ default:
674
+ return addDays(start, value);
675
+ }
676
+ }
677
+ function parseDate(s) {
678
+ return /* @__PURE__ */ new Date(s + "T00:00:00Z");
679
+ }
680
+ function maxDate(a, b) {
681
+ return a.getTime() >= b.getTime() ? a : b;
682
+ }
683
+ function minDate(a, b) {
684
+ return a.getTime() <= b.getTime() ? a : b;
685
+ }
686
+ function resolvedEnd(id, ctx) {
687
+ const item = ctx.idMap.get(id);
688
+ if (!item) return void 0;
689
+ if (item.type === "task") return item.computedEnd;
690
+ if (item.type === "milestone") return item.computedDate;
691
+ if (item.type === "parallel") return item.computedEnd;
692
+ return void 0;
693
+ }
694
+ function computeStartFromDeps(after, ctx) {
695
+ if (after.length === 0) return void 0;
696
+ let result;
697
+ for (const dep of after) {
698
+ let depDate;
699
+ if (dep.logic === "and") {
700
+ for (const id of dep.ids) {
701
+ const end = resolvedEnd(id, ctx);
702
+ if (end) depDate = depDate ? maxDate(depDate, end) : end;
703
+ }
704
+ } else {
705
+ for (const id of dep.ids) {
706
+ const end = resolvedEnd(id, ctx);
707
+ if (end) depDate = depDate ? minDate(depDate, end) : end;
708
+ }
709
+ }
710
+ if (depDate) result = result ? maxDate(result, depDate) : depDate;
711
+ }
712
+ return result;
713
+ }
714
+ function scheduleTask(task, sequentialAnchor, ctx) {
715
+ const isFixed = task.modifiers.includes("fixed") && task.startDate;
716
+ let start;
717
+ if (isFixed && task.startDate) {
718
+ start = parseDate(task.startDate);
719
+ } else {
720
+ const depStart = computeStartFromDeps(task.after, ctx);
721
+ if (task.after.length > 0) {
722
+ start = depStart ?? sequentialAnchor;
723
+ } else {
724
+ start = sequentialAnchor;
725
+ }
726
+ if (task.startDate) {
727
+ const explicit = parseDate(task.startDate);
728
+ start = maxDate(start, explicit);
729
+ }
730
+ }
731
+ task.computedStart = start;
732
+ if (task.duration) {
733
+ const bd = task.duration.unit === "bd" || ctx.useBusinessDays;
734
+ task.computedEnd = addDuration(start, task.duration, bd, ctx.weekStart);
735
+ } else {
736
+ task.computedEnd = new Date(start);
737
+ }
738
+ const blockedMod = task.modifiers.find((m) => m.startsWith("blocked:"));
739
+ if (blockedMod) {
740
+ const blockDur = parseModifierDuration(blockedMod);
741
+ if (blockDur) {
742
+ task.plannedStart = new Date(task.computedStart);
743
+ task.plannedEnd = task.computedEnd ? new Date(task.computedEnd) : void 0;
744
+ const bd = blockDur.unit === "bd" || ctx.useBusinessDays;
745
+ task.computedStart = addDuration(task.computedStart, blockDur, bd, ctx.weekStart);
746
+ if (task.duration) {
747
+ const durBd = task.duration.unit === "bd" || ctx.useBusinessDays;
748
+ task.computedEnd = addDuration(task.computedStart, task.duration, durBd, ctx.weekStart);
749
+ } else {
750
+ task.computedEnd = new Date(task.computedStart);
751
+ }
752
+ }
753
+ }
754
+ const delayedMod = task.modifiers.find((m) => m.startsWith("delayed:"));
755
+ if (delayedMod) {
756
+ const delayDur = parseModifierDuration(delayedMod);
757
+ if (delayDur) {
758
+ task.delayStart = task.computedEnd ? new Date(task.computedEnd) : void 0;
759
+ if (!task.plannedStart) {
760
+ task.plannedStart = new Date(task.computedStart);
761
+ task.plannedEnd = task.computedEnd ? new Date(task.computedEnd) : void 0;
762
+ }
763
+ const bd = delayDur.unit === "bd" || ctx.useBusinessDays;
764
+ if (task.computedEnd) {
765
+ task.computedEnd = addDuration(task.computedEnd, delayDur, bd, ctx.weekStart);
766
+ }
767
+ }
768
+ }
769
+ let subAnchor = start;
770
+ for (const sub of task.subtasks) {
771
+ scheduleTask(sub, subAnchor, ctx);
772
+ if (sub.computedEnd && sub.status !== "deferred" && sub.status !== "cancelled") {
773
+ subAnchor = sub.computedEnd;
774
+ }
775
+ }
776
+ if (task.subtasks.length > 0) {
777
+ for (const sub of task.subtasks) {
778
+ if (sub.computedEnd) {
779
+ task.computedEnd = task.computedEnd ? maxDate(task.computedEnd, sub.computedEnd) : sub.computedEnd;
780
+ }
781
+ }
782
+ }
783
+ }
784
+ function scheduleMilestone(ms, sequentialAnchor, ctx) {
785
+ const depStart = computeStartFromDeps(ms.after, ctx);
786
+ let date;
787
+ if (ms.after.length > 0) {
788
+ date = depStart ?? sequentialAnchor;
789
+ } else {
790
+ date = sequentialAnchor;
791
+ }
792
+ if (ms.date) {
793
+ const explicit = parseDate(ms.date);
794
+ date = maxDate(date, explicit);
795
+ }
796
+ ms.computedDate = date;
797
+ }
798
+ function scheduleBlock(block, sequentialAnchor, ctx) {
799
+ const depStart = computeStartFromDeps(block.after, ctx);
800
+ const blockStart = depStart ? maxDate(depStart, sequentialAnchor) : sequentialAnchor;
801
+ block.computedStart = blockStart;
802
+ let innerAnchor = blockStart;
803
+ for (const item of block.items) {
804
+ if (item.type === "task") {
805
+ scheduleTask(item, innerAnchor, ctx);
806
+ if (item.computedEnd && item.status !== "deferred" && item.status !== "cancelled") {
807
+ innerAnchor = item.computedEnd;
808
+ }
809
+ } else if (item.type === "milestone") {
810
+ scheduleMilestone(item, innerAnchor, ctx);
811
+ if (item.computedDate) innerAnchor = item.computedDate;
812
+ } else if (item.type === "parallel") {
813
+ scheduleBlock(item, innerAnchor, ctx);
814
+ }
815
+ }
816
+ let blockEnd;
817
+ for (const item of block.items) {
818
+ let itemEnd;
819
+ if (item.type === "task") itemEnd = item.computedEnd;
820
+ else if (item.type === "milestone") itemEnd = item.computedDate;
821
+ else if (item.type === "parallel") itemEnd = item.computedEnd;
822
+ if (itemEnd) blockEnd = blockEnd ? maxDate(blockEnd, itemEnd) : itemEnd;
823
+ }
824
+ block.computedEnd = blockEnd ?? blockStart;
825
+ }
826
+ function schedule(doc) {
827
+ const projectStart = doc.header.start ? parseDate(doc.header.start) : new Date(Date.UTC((/* @__PURE__ */ new Date()).getUTCFullYear(), (/* @__PURE__ */ new Date()).getUTCMonth(), (/* @__PURE__ */ new Date()).getUTCDate()));
828
+ const useBusinessDays = doc.header.schedule === "business-days";
829
+ const weekStart = doc.header.weekStart ?? "mon";
830
+ const ctx = {
831
+ projectStart,
832
+ useBusinessDays,
833
+ weekStart,
834
+ idMap: doc.idMap
835
+ };
836
+ let sequentialAnchor = projectStart;
837
+ for (const item of doc.items) {
838
+ if (item.type === "task") {
839
+ scheduleTask(item, sequentialAnchor, ctx);
840
+ if (item.computedEnd && item.status !== "deferred" && item.status !== "cancelled") {
841
+ sequentialAnchor = item.computedEnd;
842
+ }
843
+ } else if (item.type === "milestone") {
844
+ scheduleMilestone(item, sequentialAnchor, ctx);
845
+ if (item.computedDate) sequentialAnchor = item.computedDate;
846
+ } else if (item.type === "parallel") {
847
+ scheduleBlock(item, sequentialAnchor, ctx);
848
+ }
849
+ }
850
+ return doc;
851
+ }
852
+
853
+ // src/renderer/gantt-svg.ts
854
+ var STATUS_COLORS = {
855
+ new: "#93a8c4",
856
+ active: "#6a9fd8",
857
+ done: "#6aab85",
858
+ blocked: "#c97070",
859
+ "at-risk": "#c9a04a",
860
+ deferred: "#a892cc",
861
+ cancelled: "#8a96a6",
862
+ review: "#8e7ec4",
863
+ paused: "#7a90a6"
864
+ };
865
+ var STATUS_DARK = {
866
+ new: "#7a90a8",
867
+ active: "#5588c0",
868
+ done: "#56986e",
869
+ blocked: "#b85a5a",
870
+ "at-risk": "#b8902a",
871
+ deferred: "#9278b8",
872
+ cancelled: "#606878",
873
+ review: "#7868b0",
874
+ paused: "#607080"
875
+ };
876
+ function escapeXml(s) {
877
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
878
+ }
879
+ function collectRows(items, depth, rows, inBlock, block) {
880
+ for (const item of items) {
881
+ if (item.type === "task") {
882
+ rows.push({ kind: "task", task: item, depth, inBlock, block });
883
+ if (item.subtasks.length > 0) {
884
+ collectRows(item.subtasks, depth + 1, rows, inBlock, block);
885
+ }
886
+ } else if (item.type === "milestone") {
887
+ rows.push({ kind: "milestone", milestone: item, depth, inBlock, block });
888
+ } else if (item.type === "parallel") {
889
+ const blockStartRow = rows.length;
890
+ rows.push({ kind: "parallel-header", block: item, depth, blockRowStart: blockStartRow });
891
+ collectRows(item.items, depth, rows, true, item);
892
+ if (rows.length > blockStartRow) {
893
+ rows[rows.length - 1].isLastInBlock = true;
894
+ }
895
+ }
896
+ }
897
+ }
898
+ function getDates(rows) {
899
+ let min = Infinity;
900
+ let max = -Infinity;
901
+ for (const row of rows) {
902
+ if (row.kind === "task" && row.task) {
903
+ if (row.task.computedStart) min = Math.min(min, row.task.computedStart.getTime());
904
+ if (row.task.computedEnd) max = Math.max(max, row.task.computedEnd.getTime());
905
+ }
906
+ if (row.kind === "milestone" && row.milestone?.computedDate) {
907
+ const t = row.milestone.computedDate.getTime();
908
+ min = Math.min(min, t);
909
+ max = Math.max(max, t);
910
+ }
911
+ }
912
+ if (!isFinite(min)) min = Date.now();
913
+ if (!isFinite(max)) max = min + 7 * 864e5;
914
+ const span = max - min;
915
+ return {
916
+ minDate: new Date(min - span * 0.02),
917
+ maxDate: new Date(max + span * 0.05)
918
+ };
919
+ }
920
+ function getGranularity(spanDays) {
921
+ if (spanDays < 30) return "day";
922
+ if (spanDays < 180) return "week";
923
+ if (spanDays < 365) return "month";
924
+ return "quarter";
925
+ }
926
+ function generateTicks(minD, maxD, granularity) {
927
+ const ticks = [];
928
+ const cur = new Date(minD);
929
+ if (granularity === "day") {
930
+ cur.setUTCHours(0, 0, 0, 0);
931
+ while (cur <= maxD) {
932
+ ticks.push(new Date(cur));
933
+ cur.setUTCDate(cur.getUTCDate() + 1);
934
+ }
935
+ } else if (granularity === "week") {
936
+ cur.setUTCHours(0, 0, 0, 0);
937
+ const dow = cur.getUTCDay();
938
+ const toMon = dow === 0 ? -6 : 1 - dow;
939
+ cur.setUTCDate(cur.getUTCDate() + toMon);
940
+ while (cur <= maxD) {
941
+ ticks.push(new Date(cur));
942
+ cur.setUTCDate(cur.getUTCDate() + 7);
943
+ }
944
+ } else if (granularity === "month") {
945
+ cur.setUTCDate(1);
946
+ cur.setUTCHours(0, 0, 0, 0);
947
+ while (cur <= maxD) {
948
+ ticks.push(new Date(cur));
949
+ cur.setUTCMonth(cur.getUTCMonth() + 1);
950
+ }
951
+ } else {
952
+ cur.setUTCDate(1);
953
+ cur.setUTCHours(0, 0, 0, 0);
954
+ const q = Math.floor(cur.getUTCMonth() / 3);
955
+ cur.setUTCMonth(q * 3);
956
+ while (cur <= maxD) {
957
+ ticks.push(new Date(cur));
958
+ cur.setUTCMonth(cur.getUTCMonth() + 3);
959
+ }
960
+ }
961
+ return ticks;
962
+ }
963
+ function formatTick(d, granularity) {
964
+ const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
965
+ if (granularity === "day") {
966
+ return `${d.getUTCDate()} ${months[d.getUTCMonth()]}`;
967
+ }
968
+ if (granularity === "week") {
969
+ return `${months[d.getUTCMonth()]} ${d.getUTCDate()}`;
970
+ }
971
+ if (granularity === "month") {
972
+ return `${months[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
973
+ }
974
+ const q = Math.floor(d.getUTCMonth() / 3) + 1;
975
+ return `Q${q} ${d.getUTCFullYear()}`;
976
+ }
977
+ function rect(x, y, w, h, attrs = {}) {
978
+ const attrStr = Object.entries(attrs).map(([k, v]) => `${k}="${v}"`).join(" ");
979
+ return `<rect x="${x}" y="${y}" width="${Math.max(0, w)}" height="${h}" ${attrStr}/>`;
980
+ }
981
+ function text(x, y, content, attrs = {}) {
982
+ const attrStr = Object.entries(attrs).map(([k, v]) => `${k}="${v}"`).join(" ");
983
+ return `<text x="${x}" y="${y}" ${attrStr}>${escapeXml(content)}</text>`;
984
+ }
985
+ function line(x1, y1, x2, y2, attrs = {}) {
986
+ const attrStr = Object.entries(attrs).map(([k, v]) => `${k}="${v}"`).join(" ");
987
+ return `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" ${attrStr}/>`;
988
+ }
989
+ function circle(cx, cy, r, attrs = {}) {
990
+ const attrStr = Object.entries(attrs).map(([k, v]) => `${k}="${v}"`).join(" ");
991
+ return `<circle cx="${cx}" cy="${cy}" r="${r}" ${attrStr}/>`;
992
+ }
993
+ function initials(name) {
994
+ return name.split(/[\s_-]/).slice(0, 2).map((w) => w[0]?.toUpperCase() ?? "").join("");
995
+ }
996
+ function renderGanttSVG(doc, options) {
997
+ const opts = {
998
+ width: options?.width ?? 1200,
999
+ rowHeight: options?.rowHeight ?? 28,
1000
+ headerHeight: options?.headerHeight ?? 40,
1001
+ padding: options?.padding ?? 16,
1002
+ fontFamily: options?.fontFamily ?? "ui-sans-serif, system-ui, sans-serif",
1003
+ theme: options?.theme ?? "light"
1004
+ };
1005
+ const isDark = opts.theme === "dark";
1006
+ const bgColor = isDark ? "#0f172a" : "#ffffff";
1007
+ const textColor = isDark ? "#e2e8f0" : "#1e293b";
1008
+ const mutedColor = isDark ? "#64748b" : "#94a3b8";
1009
+ const trackColor = isDark ? "#1e293b" : "#f1f5f9";
1010
+ const borderColor = isDark ? "#334155" : "#e2e8f0";
1011
+ const labelWidth = 200;
1012
+ const chartLeft = opts.padding + labelWidth;
1013
+ const chartWidth = opts.width - chartLeft - opts.padding;
1014
+ const rows = [];
1015
+ collectRows(doc.items, 0, rows, false);
1016
+ const { minDate: minDate2, maxDate: maxDate2 } = getDates(rows);
1017
+ const totalMs = maxDate2.getTime() - minDate2.getTime();
1018
+ const spanDays = totalMs / 864e5;
1019
+ function dateToX(d) {
1020
+ const frac = (d.getTime() - minDate2.getTime()) / totalMs;
1021
+ return chartLeft + frac * chartWidth;
1022
+ }
1023
+ const granularity = getGranularity(spanDays);
1024
+ const ticks = generateTicks(minDate2, maxDate2, granularity);
1025
+ const totalHeight = opts.headerHeight + rows.length * opts.rowHeight + opts.padding;
1026
+ const parts = [];
1027
+ parts.push(`<defs>
1028
+ <clipPath id="chart-clip"><rect x="${chartLeft}" y="0" width="${chartWidth}" height="${totalHeight}"/></clipPath>
1029
+ </defs>`);
1030
+ parts.push(rect(0, 0, opts.width, totalHeight, { fill: bgColor }));
1031
+ if (doc.header.title) {
1032
+ parts.push(text(opts.padding, opts.headerHeight / 2 + 5, doc.header.title, {
1033
+ fill: textColor,
1034
+ "font-size": "14",
1035
+ "font-weight": "600",
1036
+ "font-family": opts.fontFamily
1037
+ }));
1038
+ }
1039
+ const axisY = opts.headerHeight - 1;
1040
+ parts.push(line(chartLeft, axisY, chartLeft + chartWidth, axisY, {
1041
+ stroke: borderColor,
1042
+ "stroke-width": "1"
1043
+ }));
1044
+ for (const tick of ticks) {
1045
+ const x = dateToX(tick);
1046
+ if (x < chartLeft || x > chartLeft + chartWidth) continue;
1047
+ parts.push(line(x, opts.headerHeight, x, totalHeight, {
1048
+ stroke: trackColor,
1049
+ "stroke-width": "1",
1050
+ "clip-path": "url(#chart-clip)"
1051
+ }));
1052
+ parts.push(line(x, axisY - 4, x, axisY, { stroke: mutedColor, "stroke-width": "1" }));
1053
+ parts.push(text(x + 3, axisY - 6, formatTick(tick, granularity), {
1054
+ fill: mutedColor,
1055
+ "font-size": "10",
1056
+ "font-family": opts.fontFamily
1057
+ }));
1058
+ }
1059
+ {
1060
+ const blockRanges = [];
1061
+ const blockStack = [];
1062
+ for (let ri = 0; ri < rows.length; ri++) {
1063
+ const row = rows[ri];
1064
+ if (row.kind === "parallel-header" && row.block) blockStack.push({ block: row.block, rowStart: ri });
1065
+ if (row.isLastInBlock && blockStack.length > 0) {
1066
+ const entry = blockStack.pop();
1067
+ blockRanges.push({ block: entry.block, rowStart: entry.rowStart, rowEnd: ri });
1068
+ }
1069
+ }
1070
+ for (const br of blockRanges) {
1071
+ const by = opts.headerHeight + br.rowStart * opts.rowHeight;
1072
+ const bh = (br.rowEnd - br.rowStart + 1) * opts.rowHeight;
1073
+ parts.push(line(opts.padding / 2, by + 4, opts.padding / 2, by + bh - 4, {
1074
+ stroke: "#6366f1",
1075
+ "stroke-width": "2",
1076
+ opacity: "0.35"
1077
+ }));
1078
+ }
1079
+ }
1080
+ for (let ri = 0; ri < rows.length; ri++) {
1081
+ const row = rows[ri];
1082
+ const rowY = opts.headerHeight + ri * opts.rowHeight;
1083
+ const midY = rowY + opts.rowHeight / 2;
1084
+ if (row.kind === "parallel-header" && row.block) {
1085
+ const indent = row.depth * 14 + opts.padding;
1086
+ const blockName = (row.block.name ?? "(parallel)").toUpperCase();
1087
+ parts.push(text(indent, midY + 4, blockName, {
1088
+ fill: isDark ? "#818cf8" : "#6366f1",
1089
+ "font-size": "9",
1090
+ "font-weight": "700",
1091
+ "letter-spacing": "0.08em",
1092
+ "font-family": opts.fontFamily
1093
+ }));
1094
+ continue;
1095
+ }
1096
+ if (row.kind === "task" && row.task) {
1097
+ const task = row.task;
1098
+ const isSubtask = row.depth > 0;
1099
+ const indent = row.depth * 14 + opts.padding;
1100
+ const color = isDark ? STATUS_DARK[task.status] : STATUS_COLORS[task.status];
1101
+ const isCancelled = task.status === "cancelled";
1102
+ const isPending = task.status === "new" || task.status === "deferred" || task.status === "paused";
1103
+ const lineColor = isCancelled ? mutedColor : color;
1104
+ const mainOpacity = isCancelled ? 0.4 : isPending ? 0.6 : 1;
1105
+ const sw = isSubtask ? 1.5 : 2;
1106
+ const dotR = isSubtask ? 3 : 4;
1107
+ parts.push(circle(indent + 4, midY, 2.5, {
1108
+ fill: lineColor,
1109
+ opacity: mainOpacity
1110
+ }));
1111
+ const labelText = task.name.length > 27 ? task.name.slice(0, 25) + "\u2026" : task.name;
1112
+ parts.push(text(indent + 12, midY + 4, labelText, {
1113
+ fill: isCancelled ? mutedColor : textColor,
1114
+ "font-size": isSubtask ? "11" : "12",
1115
+ "font-family": opts.fontFamily,
1116
+ opacity: isCancelled ? "0.45" : "1"
1117
+ }));
1118
+ if (!task.computedStart || !task.computedEnd) continue;
1119
+ const delayedMod = task.modifiers.find((m) => m.startsWith("delayed:"));
1120
+ const blockedTimeMod = task.modifiers.find((m) => m.startsWith("blocked:"));
1121
+ const taskEndDate = delayedMod && task.delayStart ? task.delayStart : task.computedEnd;
1122
+ const x1 = dateToX(task.computedStart);
1123
+ const x2 = Math.max(x1 + 5, dateToX(taskEndDate));
1124
+ parts.push(line(chartLeft, midY, chartLeft + chartWidth, midY, {
1125
+ stroke: trackColor,
1126
+ "stroke-width": "1",
1127
+ "clip-path": "url(#chart-clip)"
1128
+ }));
1129
+ if (blockedTimeMod && task.plannedStart && task.plannedEnd) {
1130
+ const gx1 = dateToX(task.plannedStart);
1131
+ const gx2 = Math.max(gx1 + 4, dateToX(task.plannedEnd));
1132
+ parts.push(line(gx1, midY, gx2, midY, {
1133
+ stroke: "#ef4444",
1134
+ "stroke-width": String(sw - 0.5),
1135
+ "stroke-dasharray": "3,3",
1136
+ "stroke-linecap": "round",
1137
+ opacity: "0.35",
1138
+ "clip-path": "url(#chart-clip)"
1139
+ }));
1140
+ parts.push(circle(gx1, midY, dotR - 1, {
1141
+ fill: "none",
1142
+ stroke: "#ef4444",
1143
+ "stroke-width": "1.5",
1144
+ opacity: "0.35",
1145
+ "clip-path": "url(#chart-clip)"
1146
+ }));
1147
+ parts.push(circle(gx2, midY, dotR - 1, {
1148
+ fill: "none",
1149
+ stroke: "#ef4444",
1150
+ "stroke-width": "1.5",
1151
+ opacity: "0.35",
1152
+ "clip-path": "url(#chart-clip)"
1153
+ }));
1154
+ if (x1 > gx2 + 6) {
1155
+ parts.push(line(gx2, midY, x1, midY, {
1156
+ stroke: "#ef4444",
1157
+ "stroke-width": "1",
1158
+ "stroke-dasharray": "2,5",
1159
+ opacity: "0.2",
1160
+ "clip-path": "url(#chart-clip)"
1161
+ }));
1162
+ }
1163
+ }
1164
+ const dashArr = isPending ? "5,4" : void 0;
1165
+ const span = x2 - x1;
1166
+ if (task.progress != null && task.progress > 0 && !isCancelled) {
1167
+ const progX = x1 + span * (task.progress / 100);
1168
+ parts.push(line(x1, midY, x2, midY, {
1169
+ stroke: lineColor,
1170
+ "stroke-width": String(sw),
1171
+ opacity: "0.2",
1172
+ "stroke-linecap": "round",
1173
+ "clip-path": "url(#chart-clip)",
1174
+ ...dashArr ? { "stroke-dasharray": dashArr } : {}
1175
+ }));
1176
+ parts.push(line(x1, midY, progX, midY, {
1177
+ stroke: lineColor,
1178
+ "stroke-width": String(sw + 0.5),
1179
+ "stroke-linecap": "round",
1180
+ "clip-path": "url(#chart-clip)"
1181
+ }));
1182
+ } else {
1183
+ parts.push(line(x1, midY, x2, midY, {
1184
+ stroke: lineColor,
1185
+ "stroke-width": String(sw),
1186
+ opacity: String(mainOpacity),
1187
+ "stroke-linecap": "round",
1188
+ "clip-path": "url(#chart-clip)",
1189
+ ...dashArr ? { "stroke-dasharray": dashArr } : {}
1190
+ }));
1191
+ }
1192
+ parts.push(circle(x1, midY, dotR, {
1193
+ fill: lineColor,
1194
+ opacity: String(mainOpacity),
1195
+ "clip-path": "url(#chart-clip)"
1196
+ }));
1197
+ if (task.status === "done") {
1198
+ parts.push(circle(x2, midY, dotR, { fill: lineColor, "clip-path": "url(#chart-clip)" }));
1199
+ } else {
1200
+ parts.push(circle(x2, midY, dotR, {
1201
+ fill: bgColor,
1202
+ stroke: lineColor,
1203
+ "stroke-width": "1.5",
1204
+ opacity: String(mainOpacity),
1205
+ "clip-path": "url(#chart-clip)"
1206
+ }));
1207
+ }
1208
+ if (delayedMod && task.delayStart && task.computedEnd) {
1209
+ const ox1 = dateToX(task.delayStart);
1210
+ const ox2 = Math.max(ox1 + 5, dateToX(task.computedEnd));
1211
+ parts.push(line(ox1, midY, ox2, midY, {
1212
+ stroke: "#f59e0b",
1213
+ "stroke-width": String(sw),
1214
+ "stroke-dasharray": "4,3",
1215
+ "stroke-linecap": "round",
1216
+ opacity: "0.7",
1217
+ "clip-path": "url(#chart-clip)"
1218
+ }));
1219
+ parts.push(circle(ox2, midY, dotR, {
1220
+ fill: "none",
1221
+ stroke: "#f59e0b",
1222
+ "stroke-width": "1.5",
1223
+ opacity: "0.7",
1224
+ "clip-path": "url(#chart-clip)"
1225
+ }));
1226
+ }
1227
+ if (task.modifiers.includes("deadline") && task.dueDate) {
1228
+ const dlX = dateToX(/* @__PURE__ */ new Date(task.dueDate + "T00:00:00Z"));
1229
+ parts.push(line(dlX, rowY + 3, dlX, rowY + opts.rowHeight - 3, {
1230
+ stroke: "#ef4444",
1231
+ "stroke-width": "1.5",
1232
+ "stroke-dasharray": "2,2",
1233
+ opacity: "0.6",
1234
+ "clip-path": "url(#chart-clip)"
1235
+ }));
1236
+ }
1237
+ const circleR = 8;
1238
+ const visualEnd = delayedMod && task.delayStart && task.computedEnd ? dateToX(task.computedEnd) : x2;
1239
+ if (task.assignees.length > 0) {
1240
+ let cx = visualEnd + circleR + 5;
1241
+ for (const assignee of task.assignees.slice(0, 3)) {
1242
+ parts.push(circle(cx, midY, circleR, {
1243
+ fill: bgColor,
1244
+ stroke: lineColor,
1245
+ "stroke-width": "1.5",
1246
+ "clip-path": "url(#chart-clip)"
1247
+ }));
1248
+ parts.push(text(cx, midY + 3.5, initials(assignee), {
1249
+ fill: textColor,
1250
+ "font-size": "7",
1251
+ "font-weight": "600",
1252
+ "text-anchor": "middle",
1253
+ "font-family": opts.fontFamily,
1254
+ "clip-path": "url(#chart-clip)"
1255
+ }));
1256
+ cx += circleR * 1.9;
1257
+ }
1258
+ }
1259
+ {
1260
+ const nameX = x1 + dotR + 4;
1261
+ const availW = chartLeft + chartWidth - nameX - 8;
1262
+ if (availW > 30) {
1263
+ const maxChars = Math.max(4, Math.floor(availW / 6.5));
1264
+ const nameLabel = task.name.length > maxChars ? task.name.slice(0, maxChars - 1) + "\u2026" : task.name;
1265
+ parts.push(text(nameX, midY - (isSubtask ? 5 : 6), nameLabel, {
1266
+ fill: mutedColor,
1267
+ "font-size": isSubtask ? "9" : "10",
1268
+ "font-family": opts.fontFamily,
1269
+ opacity: isCancelled ? "0.4" : "0.75",
1270
+ "clip-path": "url(#chart-clip)"
1271
+ }));
1272
+ }
1273
+ }
1274
+ if (task.description) {
1275
+ parts.push(`<rect x="0" y="${rowY}" width="${opts.width}" height="${opts.rowHeight}" fill="transparent" data-line="${task.line}" style="cursor:pointer"><title>${escapeXml(task.description)}</title></rect>`);
1276
+ } else {
1277
+ parts.push(rect(0, rowY, opts.width, opts.rowHeight, {
1278
+ fill: "transparent",
1279
+ "data-line": task.line,
1280
+ style: "cursor:pointer"
1281
+ }));
1282
+ }
1283
+ }
1284
+ if (row.kind === "milestone" && row.milestone) {
1285
+ const ms = row.milestone;
1286
+ const indent = row.depth * 14 + opts.padding;
1287
+ const labelText = ms.name.length > 27 ? ms.name.slice(0, 25) + "\u2026" : ms.name;
1288
+ parts.push(text(indent + 12, midY + 4, labelText, {
1289
+ fill: isDark ? "#fbbf24" : "#92400e",
1290
+ "font-size": "11",
1291
+ "font-style": "italic",
1292
+ "font-family": opts.fontFamily
1293
+ }));
1294
+ if (!ms.computedDate) continue;
1295
+ const dmx = dateToX(ms.computedDate);
1296
+ const msR = row.depth > 0 ? 4 : 5;
1297
+ if (ms.modifiers.includes("deadline")) {
1298
+ parts.push(line(dmx, opts.headerHeight, dmx, totalHeight, {
1299
+ stroke: "#ef4444",
1300
+ "stroke-width": "1",
1301
+ opacity: "0.2",
1302
+ "clip-path": "url(#chart-clip)"
1303
+ }));
1304
+ }
1305
+ parts.push(circle(dmx, midY, msR, {
1306
+ fill: isDark ? "#fbbf24" : "#d97706",
1307
+ "clip-path": "url(#chart-clip)"
1308
+ }));
1309
+ parts.push(circle(dmx, midY, msR - 2.5, {
1310
+ fill: bgColor,
1311
+ "clip-path": "url(#chart-clip)"
1312
+ }));
1313
+ parts.push(text(dmx + msR + 4, midY + 3.5, ms.name.slice(0, 18), {
1314
+ fill: isDark ? "#fbbf24" : "#92400e",
1315
+ "font-size": "9",
1316
+ "font-weight": "600",
1317
+ "font-family": opts.fontFamily,
1318
+ "clip-path": "url(#chart-clip)"
1319
+ }));
1320
+ }
1321
+ }
1322
+ const today = /* @__PURE__ */ new Date();
1323
+ today.setUTCHours(0, 0, 0, 0);
1324
+ if (today >= minDate2 && today <= maxDate2) {
1325
+ const tx = dateToX(today);
1326
+ parts.push(line(tx, opts.headerHeight, tx, totalHeight, {
1327
+ stroke: "#ef4444",
1328
+ "stroke-width": "1",
1329
+ opacity: "0.5"
1330
+ }));
1331
+ parts.push(circle(tx, opts.headerHeight, 3, { fill: "#ef4444", opacity: "0.7" }));
1332
+ }
1333
+ parts.push(line(chartLeft, 0, chartLeft, totalHeight, {
1334
+ stroke: borderColor,
1335
+ "stroke-width": "1",
1336
+ opacity: "0.6"
1337
+ }));
1338
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${opts.width}" height="${totalHeight}" viewBox="0 0 ${opts.width} ${totalHeight}" font-family="${opts.fontFamily}">
1339
+ ${parts.join("\n")}
1340
+ </svg>`;
1341
+ }
1342
+
1343
+ // cli/serve.ts
1344
+ function parseArgs(argv) {
1345
+ let folder2 = process.cwd();
1346
+ let port = 3e3;
1347
+ let noOpen2 = false;
1348
+ for (let i = 0; i < argv.length; i++) {
1349
+ if ((argv[i] === "--port" || argv[i] === "-p") && argv[i + 1]) {
1350
+ port = parseInt(argv[++i], 10);
1351
+ } else if (argv[i] === "--no-open") {
1352
+ noOpen2 = true;
1353
+ } else if (argv[i] === "--help" || argv[i] === "-h") {
1354
+ process.stdout.write([
1355
+ "",
1356
+ "yatt [folder] [options]",
1357
+ "",
1358
+ " folder Directory to watch (default: current directory)",
1359
+ " --port, -p N Port to listen on (default: 3000, auto-increments if busy)",
1360
+ " --no-open Do not open the browser automatically",
1361
+ " --help, -h Show this help",
1362
+ ""
1363
+ ].join("\n"));
1364
+ process.exit(0);
1365
+ } else if (!argv[i].startsWith("-")) {
1366
+ folder2 = path.resolve(argv[i]);
1367
+ }
1368
+ }
1369
+ return { folder: folder2, port, noOpen: noOpen2 };
1370
+ }
1371
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".svn", "dist", ".next", ".nuxt"]);
1372
+ function walkMdFiles(rootDir) {
1373
+ const results = [];
1374
+ function walk(dir) {
1375
+ let entries;
1376
+ try {
1377
+ entries = fs.readdirSync(dir, { withFileTypes: true });
1378
+ } catch {
1379
+ return;
1380
+ }
1381
+ for (const entry of entries) {
1382
+ if (entry.name.startsWith(".")) continue;
1383
+ if (SKIP_DIRS.has(entry.name)) continue;
1384
+ const full = path.join(dir, entry.name);
1385
+ if (entry.isDirectory()) walk(full);
1386
+ else if (entry.isFile() && entry.name.endsWith(".md")) results.push(full);
1387
+ }
1388
+ }
1389
+ walk(rootDir);
1390
+ return results.sort();
1391
+ }
1392
+ function parseMdBlocks(source) {
1393
+ const blocks = [];
1394
+ const lines = source.split(/\r?\n/);
1395
+ let state = "normal";
1396
+ let fenceClose = "```";
1397
+ let buf = [];
1398
+ function flushProse() {
1399
+ const t = buf.join("\n").trim();
1400
+ if (t) blocks.push({ kind: "prose", text: t });
1401
+ buf = [];
1402
+ }
1403
+ for (const line2 of lines) {
1404
+ if (state === "yatt") {
1405
+ if (line2.trimEnd() === fenceClose) {
1406
+ blocks.push({ kind: "yatt", source: buf.join("\n") });
1407
+ buf = [];
1408
+ state = "normal";
1409
+ } else {
1410
+ buf.push(line2);
1411
+ }
1412
+ continue;
1413
+ }
1414
+ if (state === "fence") {
1415
+ buf.push(line2);
1416
+ if (line2.trimEnd() === fenceClose) state = "normal";
1417
+ continue;
1418
+ }
1419
+ if (/^```yatt\s*$/.test(line2)) {
1420
+ flushProse();
1421
+ fenceClose = "```";
1422
+ state = "yatt";
1423
+ continue;
1424
+ }
1425
+ if (/^~~~yatt\s*$/.test(line2)) {
1426
+ flushProse();
1427
+ fenceClose = "~~~";
1428
+ state = "yatt";
1429
+ continue;
1430
+ }
1431
+ if (/^```/.test(line2)) {
1432
+ buf.push(line2);
1433
+ fenceClose = "```";
1434
+ state = "fence";
1435
+ continue;
1436
+ }
1437
+ if (/^~~~/.test(line2)) {
1438
+ buf.push(line2);
1439
+ fenceClose = "~~~";
1440
+ state = "fence";
1441
+ continue;
1442
+ }
1443
+ const hm = line2.match(/^(#{1,6})\s+(.+)$/);
1444
+ if (hm) {
1445
+ flushProse();
1446
+ blocks.push({ kind: "heading", level: hm[1].length, text: hm[2] });
1447
+ continue;
1448
+ }
1449
+ buf.push(line2);
1450
+ }
1451
+ flushProse();
1452
+ return blocks;
1453
+ }
1454
+ function extractTasksFromItems(items) {
1455
+ const tasks = [];
1456
+ function fmt(d) {
1457
+ return d ? d.toISOString().slice(0, 10) : void 0;
1458
+ }
1459
+ function walkTask(t, depth) {
1460
+ const dotPrefix = ".".repeat(depth);
1461
+ const info = {
1462
+ name: t.name,
1463
+ status: t.status,
1464
+ assignees: t.assignees,
1465
+ tags: t.tags,
1466
+ depth,
1467
+ dotPrefix,
1468
+ line: t.line
1469
+ };
1470
+ if (t.priority) info.priority = t.priority;
1471
+ if (t.progress !== void 0) info.progress = t.progress;
1472
+ if (t.id) info.id = t.id;
1473
+ if (t.duration) info.duration = `${t.duration.value}${t.duration.unit}`;
1474
+ if (t.startDate) info.startDate = t.startDate;
1475
+ if (t.dueDate) info.dueDate = t.dueDate;
1476
+ if (t.after.length) info.after = t.after.map((d) => d.ids.join(d.logic === "or" ? "|" : ",")).join(",");
1477
+ if (t.modifiers.length) info.modifiers = [...t.modifiers];
1478
+ if (t.description) info.description = t.description;
1479
+ const s = fmt(t.computedStart), e = fmt(t.computedEnd);
1480
+ if (s) info.start = s;
1481
+ if (e) info.end = e;
1482
+ tasks.push(info);
1483
+ for (const sub of t.subtasks) walkTask(sub, depth + 1);
1484
+ }
1485
+ function walk(its) {
1486
+ for (const item of its) {
1487
+ if (item.type === "task") walkTask(item, 0);
1488
+ else if (item.type === "parallel") walk(item.items);
1489
+ }
1490
+ }
1491
+ walk(items);
1492
+ return tasks;
1493
+ }
1494
+ function renderYattBlock(source) {
1495
+ const { doc, errors: parseErrors } = parse(source);
1496
+ const validationErrors = validate(doc);
1497
+ const scheduled = schedule(doc);
1498
+ const html = renderGanttSVG(scheduled, { theme: "dark", width: 1100 });
1499
+ const errors = [...parseErrors, ...validationErrors].map((e) => `Line ${e.line}: ${e.message}`);
1500
+ const tasks = extractTasksFromItems(doc.items);
1501
+ return { html, errors, tasks };
1502
+ }
1503
+ function renderFile(absPath) {
1504
+ const source = fs.readFileSync(absPath, "utf8");
1505
+ const blocks = parseMdBlocks(source);
1506
+ return blocks.map((b) => {
1507
+ if (b.kind === "yatt") {
1508
+ const { html, errors, tasks } = renderYattBlock(b.source);
1509
+ return { kind: "yatt", html, errors, tasks, source: b.source };
1510
+ }
1511
+ if (b.kind === "heading") return { kind: "heading", level: b.level, text: b.text };
1512
+ return { kind: "prose", text: b.text };
1513
+ });
1514
+ }
1515
+ function replaceYattBlock(fileSource, blockIdx, newSource) {
1516
+ const lines = fileSource.split(/\r?\n/);
1517
+ let idx = 0;
1518
+ let scanState = "normal";
1519
+ let fenceClose = "```";
1520
+ let contentStart = -1;
1521
+ let contentEnd = -1;
1522
+ for (let i = 0; i < lines.length; i++) {
1523
+ const ln = lines[i];
1524
+ if (scanState === "yatt") {
1525
+ if (ln.trimEnd() === fenceClose) {
1526
+ if (idx === blockIdx) {
1527
+ contentEnd = i;
1528
+ break;
1529
+ }
1530
+ idx++;
1531
+ scanState = "normal";
1532
+ }
1533
+ continue;
1534
+ }
1535
+ if (scanState === "fence") {
1536
+ if (ln.trimEnd() === fenceClose) scanState = "normal";
1537
+ continue;
1538
+ }
1539
+ if (/^```yatt\s*$/.test(ln)) {
1540
+ if (idx === blockIdx) contentStart = i + 1;
1541
+ fenceClose = "```";
1542
+ scanState = "yatt";
1543
+ continue;
1544
+ }
1545
+ if (/^~~~yatt\s*$/.test(ln)) {
1546
+ if (idx === blockIdx) contentStart = i + 1;
1547
+ fenceClose = "~~~";
1548
+ scanState = "yatt";
1549
+ continue;
1550
+ }
1551
+ if (/^```/.test(ln)) {
1552
+ fenceClose = "```";
1553
+ scanState = "fence";
1554
+ continue;
1555
+ }
1556
+ if (/^~~~/.test(ln)) {
1557
+ fenceClose = "~~~";
1558
+ scanState = "fence";
1559
+ continue;
1560
+ }
1561
+ }
1562
+ if (contentStart < 0 || contentEnd < 0) throw new Error(`YATT block ${blockIdx} not found`);
1563
+ const newLines = newSource.split(/\r?\n/);
1564
+ lines.splice(contentStart, contentEnd - contentStart, ...newLines);
1565
+ return lines.join("\n");
1566
+ }
1567
+ var SseManager = class {
1568
+ constructor() {
1569
+ this.clients = /* @__PURE__ */ new Set();
1570
+ }
1571
+ add(res) {
1572
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive" });
1573
+ res.write(":ok\n\n");
1574
+ this.clients.add(res);
1575
+ res.on("close", () => this.clients.delete(res));
1576
+ }
1577
+ broadcast(event) {
1578
+ const msg = `data: ${event}
1579
+
1580
+ `;
1581
+ for (const res of this.clients) res.write(msg);
1582
+ }
1583
+ };
1584
+ function watchFolder(rootDir, sse) {
1585
+ let debounce = null;
1586
+ const fire = () => {
1587
+ if (debounce) clearTimeout(debounce);
1588
+ debounce = setTimeout(() => sse.broadcast("reload"), 150);
1589
+ };
1590
+ try {
1591
+ fs.watch(rootDir, { recursive: true }, (_e, f) => {
1592
+ if (f && f.endsWith(".md")) fire();
1593
+ });
1594
+ } catch {
1595
+ fs.watch(rootDir, {}, (_e, f) => {
1596
+ if (f && f.endsWith(".md")) fire();
1597
+ });
1598
+ }
1599
+ }
1600
+ function openBrowser(url) {
1601
+ try {
1602
+ if (process.platform === "win32") execFile("explorer", [url], () => {
1603
+ });
1604
+ else if (process.platform === "darwin") execFile("open", [url], () => {
1605
+ });
1606
+ else execFile("xdg-open", [url], () => {
1607
+ });
1608
+ } catch {
1609
+ }
1610
+ }
1611
+ function guardPath(rootDir, relPath) {
1612
+ if (!relPath) return null;
1613
+ const absPath = path.resolve(rootDir, relPath);
1614
+ const safeRoot = rootDir.endsWith(path.sep) ? rootDir : rootDir + path.sep;
1615
+ if (!absPath.startsWith(safeRoot) && absPath !== rootDir) return null;
1616
+ return absPath;
1617
+ }
1618
+ var CSS = `
1619
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
1620
+ :root {
1621
+ --bg: #0d1117;
1622
+ --panel: #161b22;
1623
+ --panel2: #1c2128;
1624
+ --border: #30363d;
1625
+ --text: #e6edf3;
1626
+ --muted: #7d8590;
1627
+ --accent: #388bfd;
1628
+ --accent-hi: #79c0ff;
1629
+ --green: #3fb950;
1630
+ --red: #f85149;
1631
+ --orange: #f0883e;
1632
+ --yellow: #d29922;
1633
+ --purple: #bc8cff;
1634
+ --sidebar-w: 240px;
1635
+ --topbar-h: 46px;
1636
+ }
1637
+ html, body { height: 100%; background: var(--bg); color: var(--text);
1638
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
1639
+ font-size: 14px; overflow: hidden; }
1640
+ #app { display: flex; flex-direction: column; height: 100vh; }
1641
+
1642
+ /* \u2500\u2500 topbar \u2500\u2500 */
1643
+ #topbar { flex: 0 0 var(--topbar-h); display: flex; align-items: center; gap: 10px;
1644
+ padding: 0 14px; background: var(--panel); border-bottom: 1px solid var(--border); z-index: 10; }
1645
+ .logo { font-size: 13px; font-weight: 700; letter-spacing: 0.12em; color: var(--accent-hi);
1646
+ text-transform: uppercase; flex-shrink: 0; }
1647
+ #folder-path { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis;
1648
+ white-space: nowrap; flex: 1; min-width: 0; }
1649
+ #view-tabs { display: flex; gap: 2px; flex-shrink: 0; }
1650
+ .tab { background: none; border: none; cursor: pointer; padding: 5px 12px;
1651
+ font-size: 12px; color: var(--muted); border-radius: 4px;
1652
+ transition: color 0.1s, background 0.1s; font-family: inherit; }
1653
+ .tab:hover { color: var(--text); background: rgba(255,255,255,0.06); }
1654
+ .tab.active { color: var(--accent-hi); background: rgba(56,139,253,0.12); }
1655
+ #save-status { font-size: 11px; color: var(--muted); flex-shrink: 0; min-width: 60px; text-align: right; }
1656
+ #save-status.unsaved { color: var(--orange); }
1657
+ #save-status.saved { color: var(--green); }
1658
+ #save-status.error { color: var(--red); }
1659
+
1660
+ /* \u2500\u2500 git widget \u2500\u2500 */
1661
+ #git-widget { display: flex; align-items: center; gap: 6px; flex-shrink: 0; margin-left: auto; }
1662
+ #git-widget.hidden { display: none; }
1663
+ .git-branch { display: flex; align-items: center; gap: 4px; font-size: 11px; color: var(--muted);
1664
+ background: var(--panel2); border: 1px solid var(--border); border-radius: 4px; padding: 2px 7px; }
1665
+ .git-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); flex-shrink: 0; }
1666
+ .git-dot.dirty { background: var(--orange); }
1667
+ .git-count { font-size: 10px; color: var(--muted); display: flex; align-items: center; gap: 2px; }
1668
+ .git-count.ahead { color: #6a9fd8; }
1669
+ .git-count.behind { color: var(--orange); }
1670
+ .git-btn { background: var(--panel2); border: 1px solid var(--border); color: var(--text);
1671
+ border-radius: 4px; padding: 2px 9px; font-size: 11px; cursor: pointer; white-space: nowrap; }
1672
+ .git-btn:hover { border-color: var(--accent); color: var(--accent-hi); }
1673
+ .git-btn:disabled { opacity: 0.4; cursor: default; }
1674
+ .git-btn.primary { border-color: var(--accent); color: var(--accent-hi); }
1675
+ #git-msg { font-size: 11px; max-width: 200px; overflow: hidden; text-overflow: ellipsis;
1676
+ white-space: nowrap; flex-shrink: 0; }
1677
+ #git-msg.ok { color: var(--green); }
1678
+ #git-msg.err { color: var(--red); }
1679
+ /* commit modal */
1680
+ #git-commit-modal { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 1001;
1681
+ display: flex; align-items: center; justify-content: center; }
1682
+ #git-commit-modal.hidden { display: none; }
1683
+ #git-commit-box { background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
1684
+ padding: 20px; width: 360px; display: flex; flex-direction: column; gap: 12px; }
1685
+ #git-commit-box h3 { margin: 0; font-size: 14px; color: var(--text); }
1686
+ #git-commit-msg { width: 100%; box-sizing: border-box; background: var(--panel2); border: 1px solid var(--border);
1687
+ color: var(--text); border-radius: 5px; padding: 8px 10px; font-size: 12px; font-family: inherit;
1688
+ resize: vertical; min-height: 60px; }
1689
+ #git-commit-msg:focus { outline: none; border-color: var(--accent); }
1690
+ .git-commit-actions { display: flex; justify-content: flex-end; gap: 8px; }
1691
+
1692
+ /* \u2500\u2500 workspace \u2500\u2500 */
1693
+ #workspace { flex: 1; display: flex; min-height: 0; }
1694
+
1695
+ /* \u2500\u2500 sidebar \u2500\u2500 */
1696
+ #sidebar { flex: 0 0 var(--sidebar-w); overflow-y: auto; border-right: 1px solid var(--border);
1697
+ background: var(--panel); scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
1698
+ #sidebar::-webkit-scrollbar { width: 4px; }
1699
+ #sidebar::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
1700
+ #sidebar-inner { padding: 6px 0; }
1701
+ .group-label { font-size: 10px; font-weight: 600; text-transform: uppercase;
1702
+ letter-spacing: 0.08em; color: var(--muted); padding: 10px 14px 3px; }
1703
+ .file-item { display: block; padding: 5px 14px; font-size: 12px; color: var(--text);
1704
+ cursor: pointer; text-decoration: none; white-space: nowrap; overflow: hidden;
1705
+ text-overflow: ellipsis; border-left: 2px solid transparent; transition: background 0.1s; }
1706
+ .file-item:hover { background: rgba(56,139,253,0.08); }
1707
+ .file-item.active { background: rgba(56,139,253,0.14); color: var(--accent-hi); border-left-color: var(--accent); }
1708
+
1709
+ /* \u2500\u2500 main \u2500\u2500 */
1710
+ #main { flex: 1; display: flex; flex-direction: column; min-width: 0; overflow: hidden; }
1711
+ .view-panel { flex: 1; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
1712
+ .view-panel::-webkit-scrollbar { width: 6px; }
1713
+ .view-panel::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
1714
+
1715
+ /* \u2500\u2500 document view \u2500\u2500 */
1716
+ #doc-content { max-width: 1200px; margin: 0 auto; padding: 28px 36px 80px; }
1717
+ .block-h1 { font-size: 24px; font-weight: 700; margin: 32px 0 12px;
1718
+ padding-bottom: 8px; border-bottom: 1px solid var(--border); }
1719
+ .block-h2 { font-size: 18px; font-weight: 600; margin: 24px 0 8px; }
1720
+ .block-h3, .block-h4, .block-h5, .block-h6 { font-size: 14px; font-weight: 600; margin: 18px 0 6px; color: var(--muted); }
1721
+ .prose { line-height: 1.65; color: var(--text); margin: 12px 0; }
1722
+ .prose p { margin: 6px 0; }
1723
+ .prose ul, .prose ol { padding-left: 20px; margin: 6px 0; }
1724
+ .prose li { margin: 3px 0; }
1725
+ .prose strong { font-weight: 600; }
1726
+ .prose em { font-style: italic; }
1727
+ .prose a { color: var(--accent-hi); }
1728
+ .prose h1,.prose h2,.prose h3,.prose h4 { font-weight: 600; margin: 14px 0 6px; }
1729
+ .prose blockquote { border-left: 2px solid var(--border); padding-left: 12px; color: var(--muted); margin: 8px 0; }
1730
+ .prose hr { border: none; border-top: 1px solid var(--border); margin: 16px 0; }
1731
+ .prose code { font-family: ui-monospace, monospace; font-size: 12px;
1732
+ background: var(--panel2); padding: 1px 5px; border-radius: 3px; color: var(--accent-hi); }
1733
+ .prose pre { background: var(--panel2); border: 1px solid var(--border); border-radius: 6px;
1734
+ padding: 14px 16px; overflow-x: auto; margin: 10px 0; }
1735
+ .prose pre code { background: none; padding: 0; font-size: 12px; color: var(--text); }
1736
+ .prose table { border-collapse: collapse; width: 100%; margin: 10px 0; font-size: 13px; }
1737
+ .prose th { text-align: left; padding: 6px 12px; background: var(--panel2);
1738
+ border: 1px solid var(--border); font-weight: 600; }
1739
+ .prose td { padding: 6px 12px; border: 1px solid var(--border); }
1740
+ .prose tr:nth-child(even) td { background: rgba(255,255,255,0.02); }
1741
+
1742
+ /* \u2500\u2500 yatt control (embedded per-block) \u2500\u2500 */
1743
+ .yatt-ctrl { margin: 16px 0; border: 1px solid var(--border); border-radius: 8px; overflow: auto;
1744
+ display: flex; flex-direction: column; resize: vertical; min-height: 400px; }
1745
+ .yatt-ctrl-bar { display: flex; gap: 2px; padding: 4px 8px; flex: 0 0 auto;
1746
+ border-bottom: 1px solid var(--border); background: var(--panel2); }
1747
+ .yatt-ctrl-tab { background: none; border: none; cursor: pointer; padding: 3px 10px;
1748
+ font-size: 11px; color: var(--muted); border-radius: 3px;
1749
+ transition: color 0.1s, background 0.1s; font-family: inherit; }
1750
+ .yatt-ctrl-tab:hover { color: var(--text); background: rgba(255,255,255,0.06); }
1751
+ .yatt-ctrl-tab.active { color: var(--accent-hi); background: rgba(56,139,253,0.12); }
1752
+ .yatt-ctrl-body { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: auto; }
1753
+ .yatt-ctrl-panel { display: none; }
1754
+ .yatt-ctrl-panel.active { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: auto; }
1755
+ .yatt-ctrl-panel[data-panel="timeline"] svg { max-width: 100%; height: auto; display: block; }
1756
+ .yatt-errors { background: rgba(248,81,73,0.08); border-bottom: 1px solid rgba(248,81,73,0.25);
1757
+ padding: 6px 12px; font-size: 11px; color: var(--red); font-family: ui-monospace, monospace; }
1758
+
1759
+ /* \u2500\u2500 kanban (inside control and shared atoms) \u2500\u2500 */
1760
+ .ctrl-kanban { display: flex; gap: 10px; padding: 12px; overflow-x: auto; align-items: flex-start; }
1761
+ .k-col { flex: 0 0 200px; background: var(--bg); border-radius: 6px;
1762
+ border: 1px solid var(--border); display: flex; flex-direction: column; max-height: 360px; }
1763
+ .k-col.empty { flex: 0 0 32px; cursor: default; }
1764
+ .k-col.empty .k-col-header { display: none; }
1765
+ .k-col.empty .k-cards { display: none; }
1766
+ .k-col-empty-label { display: none; }
1767
+ .k-col.empty .k-col-empty-label { display: flex; flex-direction: column; align-items: center;
1768
+ justify-content: center; height: 100%; gap: 8px; padding: 10px 0; }
1769
+ .k-col-empty-line { width: 3px; border-radius: 2px; flex-shrink: 0; min-height: 24px; flex: 1; max-height: 60px; }
1770
+ .k-col-empty-title { writing-mode: vertical-rl; text-orientation: mixed; transform: rotate(180deg);
1771
+ font-size: 9px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); }
1772
+ .k-col-header { padding: 8px 12px; border-bottom: 1px solid var(--border);
1773
+ display: flex; align-items: center; gap: 7px; flex-shrink: 0; }
1774
+ .k-col-line { width: 3px; height: 12px; border-radius: 2px; flex-shrink: 0; }
1775
+ .k-col-title { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.07em; flex: 1; }
1776
+ .k-col-count { font-size: 11px; color: var(--muted); }
1777
+ .k-cards { overflow-y: auto; padding: 6px; display: flex; flex-direction: column;
1778
+ gap: 5px; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
1779
+ .k-cards::-webkit-scrollbar { width: 3px; }
1780
+ .k-cards::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
1781
+ .k-card { background: var(--panel); border: 1px solid var(--border); border-radius: 5px;
1782
+ padding: 8px 10px; transition: border-color 0.12s; }
1783
+ .k-card:hover { border-color: var(--accent); }
1784
+ .k-card-name { font-size: 11px; color: var(--text); line-height: 1.4; margin-bottom: 3px; }
1785
+ .k-card-desc { font-size: 10px; color: var(--muted); line-height: 1.4; margin-bottom: 5px; font-style: italic; }
1786
+ .k-shift-badge { display: inline-flex; align-items: center; gap: 3px; font-size: 10px; font-weight: 600;
1787
+ border-radius: 3px; padding: 1px 5px; margin-right: 3px; }
1788
+ .k-shift-badge.delayed { background: rgba(245,158,11,0.15); color: #f59e0b; }
1789
+ .k-shift-badge.blocked { background: rgba(239,68,68,0.15); color: #ef4444; }
1790
+ .k-card-shifts { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 5px; }
1791
+ .k-card-meta { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; }
1792
+ .k-progress { height: 2px; background: var(--border); border-radius: 1px; margin-top: 6px; overflow: hidden; }
1793
+ .k-progress-fill { height: 100%; border-radius: 1px; background: var(--accent); }
1794
+ .k-priority { font-size: 10px; padding: 1px 5px; border-radius: 3px;
1795
+ background: rgba(255,255,255,0.06); color: var(--muted); }
1796
+ .k-priority[data-p="critical"] { color: var(--red); }
1797
+ .k-priority[data-p="high"] { color: var(--orange); }
1798
+
1799
+ /* \u2500\u2500 people (inside control) \u2500\u2500 */
1800
+ .ctrl-people { padding: 12px 16px; display: flex; flex-direction: column; gap: 12px; }
1801
+ .person-card { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
1802
+ .person-header { display: flex; align-items: center; gap: 10px; padding: 10px 14px;
1803
+ border-bottom: 1px solid var(--border); }
1804
+ .person-name { font-size: 12px; font-weight: 600; }
1805
+ .person-count { font-size: 11px; color: var(--muted); margin-top: 1px; }
1806
+ .ptask-row { display: flex; align-items: center; gap: 8px; padding: 6px 14px;
1807
+ border-bottom: 1px solid var(--border); }
1808
+ .ptask-row:last-child { border-bottom: none; }
1809
+ .ptask-name { font-size: 12px; flex: 1; }
1810
+ .ptask-priority { font-size: 10px; color: var(--muted); }
1811
+ .ptask-priority[data-p="critical"] { color: var(--red); }
1812
+ .ptask-priority[data-p="high"] { color: var(--orange); }
1813
+ .ptask-prog { font-size: 10px; color: var(--muted); font-family: ui-monospace, monospace; }
1814
+
1815
+ /* \u2500\u2500 markdown/edit view \u2500\u2500 */
1816
+ #view-markdown { display: flex; flex-direction: column; overflow: hidden; }
1817
+ #view-markdown.view-panel { overflow: hidden; }
1818
+ .view-panel[hidden] { display: none !important; }
1819
+ #editor { display: block; flex: 1 1 0; min-height: 0; width: 100%; resize: none;
1820
+ background: var(--bg); color: var(--text); border: none; outline: none;
1821
+ padding: 24px 32px; font-family: ui-monospace, 'Cascadia Code', 'Fira Code', monospace;
1822
+ font-size: 13px; line-height: 1.65; tab-size: 2; }
1823
+
1824
+ /* \u2500\u2500 yatt source panel (read-only) \u2500\u2500 */
1825
+ .yatt-src { padding: 16px; font-family: ui-monospace, 'Cascadia Code', monospace;
1826
+ font-size: 12px; line-height: 1.6; overflow-x: auto;
1827
+ background: var(--bg); color: var(--text); white-space: pre; margin: 0; }
1828
+
1829
+ /* \u2500\u2500 yatt inline block editor \u2500\u2500 */
1830
+ .yatt-block-editor { display: block; width: 100%; flex: 1; min-height: 0; padding: 14px 16px;
1831
+ font-family: ui-monospace, 'Cascadia Code', 'Fira Code', monospace;
1832
+ font-size: 12px; line-height: 1.6; background: var(--bg); color: var(--text);
1833
+ border: none; outline: none; resize: none; tab-size: 2; }
1834
+ .yatt-block-bar { display: flex; align-items: center; padding: 4px 12px;
1835
+ border-top: 1px solid var(--border); background: var(--panel2); min-height: 26px; }
1836
+ .yatt-block-status { font-size: 10px; }
1837
+
1838
+ /* \u2500\u2500 task edit modal \u2500\u2500 */
1839
+ #task-edit-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: 1000;
1840
+ display: flex; align-items: center; justify-content: center; }
1841
+ #task-edit-overlay.hidden { display: none; }
1842
+ #task-edit-modal { background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
1843
+ padding: 22px 24px; width: 500px; max-height: 88vh; overflow-y: auto;
1844
+ scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
1845
+ .te-title { font-size: 15px; font-weight: 700; margin-bottom: 16px; color: var(--text); }
1846
+ .te-row { display: grid; gap: 10px; margin-bottom: 10px; }
1847
+ .te-row.cols-2 { grid-template-columns: 1fr 1fr; }
1848
+ .te-row.cols-3 { grid-template-columns: 1fr 1fr 1fr; }
1849
+ .te-field label { display: block; font-size: 10px; font-weight: 600; text-transform: uppercase;
1850
+ letter-spacing: 0.06em; color: var(--muted); margin-bottom: 4px; }
1851
+ .te-field input, .te-field select { width: 100%; background: var(--bg); border: 1px solid var(--border);
1852
+ border-radius: 5px; color: var(--text); padding: 5px 8px; font-size: 12px; font-family: inherit;
1853
+ outline: none; transition: border-color 0.1s; }
1854
+ .te-field input:focus, .te-field select:focus { border-color: var(--accent); }
1855
+ .te-field textarea { width: 100%; background: var(--bg); border: 1px solid var(--border);
1856
+ border-radius: 5px; color: var(--text); padding: 5px 8px; font-size: 12px; font-family: inherit;
1857
+ outline: none; transition: border-color 0.1s; resize: vertical; min-height: 56px; box-sizing: border-box; }
1858
+ .te-field textarea:focus { border-color: var(--accent); }
1859
+ .te-field select option { background: var(--panel); }
1860
+ .te-actions { display: flex; align-items: center; gap: 8px; margin-top: 18px; }
1861
+ .te-save-msg { font-size: 11px; color: var(--muted); flex: 1; }
1862
+ .te-btn { padding: 5px 16px; border-radius: 5px; border: none; cursor: pointer;
1863
+ font-size: 12px; font-family: inherit; font-weight: 500; transition: opacity 0.1s; }
1864
+ .te-btn-primary { background: var(--accent); color: #fff; }
1865
+ .te-btn-primary:hover { opacity: 0.85; }
1866
+ .te-btn-ghost { background: rgba(255,255,255,0.06); color: var(--text); border: 1px solid var(--border); }
1867
+ .te-btn-ghost:hover { background: rgba(255,255,255,0.1); }
1868
+
1869
+ /* \u2500\u2500 kanban drag \u2500\u2500 */
1870
+ .k-col.drag-over .k-cards { background: rgba(56,139,253,0.08); border-radius: 0 0 6px 6px; }
1871
+ .k-card.dragging { opacity: 0.35; }
1872
+ .k-card[data-line] { cursor: pointer; }
1873
+ .k-card[data-line]:hover { border-color: var(--accent-hi); }
1874
+ .ptask-row[data-line] { cursor: pointer; }
1875
+ .ptask-row[data-line]:hover { background: rgba(56,139,253,0.06); }
1876
+
1877
+ /* \u2500\u2500 gantt hover card \u2500\u2500 */
1878
+ #gantt-hover-card { display:none; position:fixed; z-index:9999; pointer-events:none;
1879
+ background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
1880
+ padding: 10px 12px; min-width: 180px; max-width: 260px;
1881
+ box-shadow: 0 8px 24px rgba(0,0,0,0.25); font-size: 12px; }
1882
+ .ghc-name { font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 6px; line-height: 1.3; }
1883
+ .ghc-row { display: flex; align-items: center; gap: 6px; color: var(--muted); margin-bottom: 3px; font-size: 11px; }
1884
+ .ghc-status { display: inline-flex; align-items: center; gap: 4px; }
1885
+ .ghc-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
1886
+ .ghc-desc { color: var(--muted); font-style: italic; font-size: 11px; margin-top: 6px;
1887
+ padding-top: 6px; border-top: 1px solid var(--border); line-height: 1.4; }
1888
+ .ghc-tags { display: flex; flex-wrap: wrap; gap: 3px; margin-top: 5px; }
1889
+ .ghc-tag { background: var(--border); color: var(--muted); border-radius: 3px; padding: 1px 5px; font-size: 10px; }
1890
+
1891
+ /* \u2500\u2500 shared atoms \u2500\u2500 */
1892
+ .sdot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
1893
+ .avatar { display: inline-flex; align-items: center; justify-content: center;
1894
+ width: 20px; height: 20px; border-radius: 50%; background: var(--border);
1895
+ font-size: 9px; font-weight: 700; color: var(--text); flex-shrink: 0; }
1896
+ .avatar-lg { width: 32px; height: 32px; font-size: 11px; }
1897
+ .loading { color: var(--muted); padding: 40px; text-align: center; font-size: 13px; }
1898
+ .err { color: var(--red) !important; }
1899
+ `;
1900
+ var JS = `
1901
+ var state = {
1902
+ files: [], currentFile: null, view: 'view',
1903
+ blocks: null, source: null, saveStatus: '', saveTimer: null,
1904
+ editTask: null, editCtrlId: null, editBidx: null
1905
+ };
1906
+
1907
+ // Persists the active panel for each yatt ctrl across SSE reloads
1908
+ var ctrlViews = {};
1909
+
1910
+ var STATUS_COLOR = {
1911
+ 'new':'#7d8590','active':'#388bfd','done':'#3fb950','blocked':'#f85149',
1912
+ 'at-risk':'#f0883e','deferred':'#bc8cff','cancelled':'#30363d',
1913
+ 'review':'#d29922','paused':'#484f58'
1914
+ };
1915
+ var STATUS_LABEL = {
1916
+ 'new':'New','active':'Active','done':'Done','blocked':'Blocked',
1917
+ 'at-risk':'At Risk','deferred':'Deferred','cancelled':'Cancelled',
1918
+ 'review':'Review','paused':'Paused'
1919
+ };
1920
+ var KANBAN_COLS = ['new','active','review','blocked','paused','done','cancelled'];
1921
+
1922
+ var STATUS_SIGIL = {
1923
+ 'new':' ','active':'~','done':'x','blocked':'!',
1924
+ 'at-risk':'?','deferred':'>','cancelled':'_','review':'=','paused':'o'
1925
+ };
1926
+
1927
+ // \u2500\u2500 Task serializer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1928
+ function serializeTaskLine(t) {
1929
+ var sig = STATUS_SIGIL[t.status] || ' ';
1930
+ var dp = t.dotPrefix || '';
1931
+ var head = (dp ? dp + ' ' : '') + '[' + sig + '] ' + (t.name || '');
1932
+ var fields = [];
1933
+ if (t.id) fields.push('id:' + t.id);
1934
+ if (t.duration) fields.push(t.duration);
1935
+ if (t.assignees && t.assignees.length) fields.push(t.assignees.map(function(a){return '@'+a;}).join(' '));
1936
+ if (t.tags && t.tags.length) fields.push(t.tags.map(function(a){return '#'+a;}).join(' '));
1937
+ if (t.priority && t.priority !== 'normal') fields.push('!' + t.priority);
1938
+ if (t.progress != null && t.progress !== '') fields.push('%' + t.progress);
1939
+ if (t.startDate) fields.push('>' + t.startDate);
1940
+ if (t.dueDate) fields.push('<' + t.dueDate);
1941
+ if (t.after) fields.push('after:' + t.after);
1942
+ if (t.modifiers && t.modifiers.length) t.modifiers.forEach(function(m) {
1943
+ var shiftMatch = m.match(/^(delayed|blocked):(.+)$/);
1944
+ if (shiftMatch) { fields.push(shiftMatch[1] + ' ' + shiftMatch[2]); }
1945
+ else { fields.push('+' + m); }
1946
+ });
1947
+ return head + (fields.length ? ' | ' + fields.join(' | ') : '');
1948
+ }
1949
+
1950
+ function patchBlockSource(source, lineNum, newLine) {
1951
+ var lines = source.split('\\n');
1952
+ if (lineNum >= 1 && lineNum <= lines.length) lines[lineNum - 1] = newLine;
1953
+ return lines.join('\\n');
1954
+ }
1955
+
1956
+ function patchBlockSourceWithDescription(source, lineNum, newLine, descText) {
1957
+ var lines = source.split('\\n');
1958
+ if (lineNum < 1 || lineNum > lines.length) return source;
1959
+ // Count existing description comment lines immediately after the task line
1960
+ var oldDescCount = 0;
1961
+ var idx = lineNum; // 0-based index of the line after the task
1962
+ while (idx < lines.length && lines[idx].trimStart().startsWith('//')) {
1963
+ oldDescCount++;
1964
+ idx++;
1965
+ }
1966
+ // Build replacement: task line + optional description comment lines
1967
+ var newLines = [newLine];
1968
+ if (descText && descText.trim()) {
1969
+ descText.trim().split('\\n').forEach(function(dl) {
1970
+ newLines.push('// ' + dl.trim());
1971
+ });
1972
+ }
1973
+ Array.prototype.splice.apply(lines, [lineNum - 1, 1 + oldDescCount].concat(newLines));
1974
+ return lines.join('\\n');
1975
+ }
1976
+
1977
+ function saveBlock(bidx, newSource, onDone) {
1978
+ if (!state.currentFile) return;
1979
+ // Update local cache so in-flight UI doesn't see stale source
1980
+ var yblocks = (state.blocks || []).filter(function(b){ return b.kind === 'yatt'; });
1981
+ if (yblocks[bidx]) yblocks[bidx].source = newSource;
1982
+ fetch('/api/save-block?p=' + encodeURIComponent(state.currentFile) + '&idx=' + bidx, {
1983
+ method: 'POST', headers: {'Content-Type':'text/plain;charset=utf-8'}, body: newSource
1984
+ }).then(function(r) {
1985
+ if (r.ok) { if (onDone) onDone(null); refreshGitStatus(); }
1986
+ else r.json().then(function(d){ if (onDone) onDone(d.error || 'Error'); });
1987
+ }).catch(function(e){ if (onDone) onDone(e.message); });
1988
+ }
1989
+
1990
+ // \u2500\u2500 Task edit popup \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1991
+ function openTaskEdit(task) {
1992
+ if (!task) return;
1993
+ state.editTask = task;
1994
+ document.getElementById('te-name').value = task.name || '';
1995
+ document.getElementById('te-status').value = task.status || 'new';
1996
+ document.getElementById('te-assignees').value = (task.assignees||[]).map(function(a){return '@'+a;}).join(' ');
1997
+ document.getElementById('te-tags').value = (task.tags||[]).map(function(a){return '#'+a;}).join(' ');
1998
+ document.getElementById('te-priority').value = task.priority || 'normal';
1999
+ document.getElementById('te-progress').value = task.progress != null ? task.progress : '';
2000
+ document.getElementById('te-duration').value = task.duration || '';
2001
+ document.getElementById('te-startdate').value = task.startDate || '';
2002
+ document.getElementById('te-duedate').value = task.dueDate || '';
2003
+ document.getElementById('te-id').value = task.id || '';
2004
+ document.getElementById('te-after').value = task.after || '';
2005
+ document.getElementById('te-description').value = task.description || '';
2006
+ var mods = task.modifiers || [];
2007
+ var delayedMod = mods.find(function(m){ return m.match(/^delayed:/); });
2008
+ var blockedMod = mods.find(function(m){ return m.match(/^blocked:/); });
2009
+ document.getElementById('te-delayed').value = delayedMod ? delayedMod.split(':')[1] : '';
2010
+ document.getElementById('te-blocked').value = blockedMod ? blockedMod.split(':')[1] : '';
2011
+ document.getElementById('te-save-msg').textContent = '';
2012
+ document.getElementById('task-edit-overlay').classList.remove('hidden');
2013
+ setTimeout(function(){ document.getElementById('te-name').focus(); }, 50);
2014
+ }
2015
+
2016
+ function closeTaskEdit() {
2017
+ document.getElementById('task-edit-overlay').classList.add('hidden');
2018
+ state.editTask = null;
2019
+ }
2020
+
2021
+ function saveTaskEdit() {
2022
+ var task = state.editTask;
2023
+ if (!task) return;
2024
+ var updated = {};
2025
+ for (var k in task) updated[k] = task[k];
2026
+ updated.name = document.getElementById('te-name').value.trim();
2027
+ updated.status = document.getElementById('te-status').value;
2028
+ var ar = document.getElementById('te-assignees').value.trim();
2029
+ updated.assignees = ar ? ar.split(/\\s+/).map(function(a){return a.replace(/^@/,'');}).filter(Boolean) : [];
2030
+ var tr = document.getElementById('te-tags').value.trim();
2031
+ updated.tags = tr ? tr.split(/\\s+/).map(function(a){return a.replace(/^#/,'');}).filter(Boolean) : [];
2032
+ updated.priority = document.getElementById('te-priority').value || 'normal';
2033
+ var pg = document.getElementById('te-progress').value;
2034
+ updated.progress = pg !== '' ? parseInt(pg, 10) : null;
2035
+ updated.duration = document.getElementById('te-duration').value.trim() || null;
2036
+ updated.startDate = document.getElementById('te-startdate').value.trim() || null;
2037
+ updated.dueDate = document.getElementById('te-duedate').value.trim() || null;
2038
+ updated.id = document.getElementById('te-id').value.trim() || null;
2039
+ updated.after = document.getElementById('te-after').value.trim() || null;
2040
+ updated.description = document.getElementById('te-description').value.trim() || null;
2041
+ // Rebuild modifiers: keep non-shift ones, then append updated delayed/blocked
2042
+ var baseMods = (updated.modifiers || []).filter(function(m){
2043
+ return !m.match(/^(delayed|blocked):/);
2044
+ });
2045
+ var delayedVal = document.getElementById('te-delayed').value.trim();
2046
+ var blockedVal = document.getElementById('te-blocked').value.trim();
2047
+ if (delayedVal) baseMods.push('delayed:' + delayedVal);
2048
+ if (blockedVal) baseMods.push('blocked:' + blockedVal);
2049
+ updated.modifiers = baseMods;
2050
+
2051
+ var bidx = state.editTask.bidx;
2052
+ var yblocks = (state.blocks || []).filter(function(b){ return b.kind === 'yatt'; });
2053
+ var block = yblocks[bidx];
2054
+ if (!block || !block.source) {
2055
+ document.getElementById('te-save-msg').style.color = 'var(--red)';
2056
+ document.getElementById('te-save-msg').textContent = 'Error: block source not found';
2057
+ return;
2058
+ }
2059
+ var newLine = serializeTaskLine(updated);
2060
+ var newSource = patchBlockSourceWithDescription(block.source, task.line, newLine, updated.description);
2061
+ var msgEl = document.getElementById('te-save-msg');
2062
+ msgEl.style.color = 'var(--muted)'; msgEl.textContent = 'Saving...';
2063
+ saveBlock(bidx, newSource, function(err) {
2064
+ if (err) { msgEl.style.color='var(--red)'; msgEl.textContent='Error: '+err; }
2065
+ else { closeTaskEdit(); }
2066
+ });
2067
+ }
2068
+
2069
+ // \u2500\u2500 Yatt ctrl init (called after each block renders) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2070
+ function findTask(bidx, line) {
2071
+ var yblocks = (state.blocks || []).filter(function(b){ return b.kind === 'yatt'; });
2072
+ var block = yblocks[bidx];
2073
+ if (!block) return null;
2074
+ return (block.tasks || []).find(function(t){ return t.line === line; }) || null;
2075
+ }
2076
+
2077
+ function initYattCtrl(ctrlId, bidx) {
2078
+ var ctrl = document.getElementById(ctrlId);
2079
+ if (!ctrl) return;
2080
+ ctrl.setAttribute('data-bidx', bidx);
2081
+
2082
+ // Timeline: click + hover on task rows
2083
+ var svgEl = ctrl.querySelector('[data-panel="timeline"] svg');
2084
+ if (svgEl) {
2085
+ svgEl.addEventListener('click', function(e) {
2086
+ var el = e.target;
2087
+ while (el && el !== svgEl) {
2088
+ var dl = el.getAttribute ? el.getAttribute('data-line') : null;
2089
+ if (dl) {
2090
+ var task = findTask(bidx, parseInt(dl));
2091
+ if (task) { task.bidx = bidx; openTaskEdit(task); }
2092
+ return;
2093
+ }
2094
+ el = el.parentElement;
2095
+ }
2096
+ });
2097
+ svgEl.addEventListener('mousemove', function(e) {
2098
+ var el = e.target;
2099
+ while (el && el !== svgEl) {
2100
+ var dl = el.getAttribute ? el.getAttribute('data-line') : null;
2101
+ if (dl) {
2102
+ var task = findTask(bidx, parseInt(dl));
2103
+ if (task) { showGanttHoverCard(task, e.clientX, e.clientY); return; }
2104
+ }
2105
+ el = el.parentElement;
2106
+ }
2107
+ hideGanttHoverCard();
2108
+ });
2109
+ svgEl.addEventListener('mouseleave', hideGanttHoverCard);
2110
+ }
2111
+
2112
+ // Kanban: click-to-edit + drag-drop
2113
+ var kbEl = ctrl.querySelector('[data-panel="kanban"] .ctrl-kanban');
2114
+ if (kbEl) initKanban(kbEl, bidx);
2115
+
2116
+ // People: click-to-edit
2117
+ var peEl = ctrl.querySelector('[data-panel="people"]');
2118
+ if (peEl) {
2119
+ peEl.querySelectorAll('.ptask-row[data-line]').forEach(function(row) {
2120
+ row.addEventListener('click', function() {
2121
+ var task = findTask(bidx, parseInt(row.getAttribute('data-line')));
2122
+ if (task) { task.bidx = bidx; openTaskEdit(task); }
2123
+ });
2124
+ });
2125
+ }
2126
+
2127
+ // Markdown tab: auto-save textarea
2128
+ var mdPanel = ctrl.querySelector('[data-panel="markdown"]');
2129
+ if (mdPanel) {
2130
+ var ta = mdPanel.querySelector('.yatt-block-editor');
2131
+ var statusEl = mdPanel.querySelector('.yatt-block-status');
2132
+ if (ta) {
2133
+ var bsTimer = null;
2134
+ ta.addEventListener('input', function() {
2135
+ if (bsTimer) clearTimeout(bsTimer);
2136
+ if (statusEl) { statusEl.style.color = 'var(--orange)'; statusEl.textContent = 'Unsaved'; }
2137
+ bsTimer = setTimeout(function() {
2138
+ bsTimer = null;
2139
+ saveBlock(bidx, ta.value, function(err) {
2140
+ if (!statusEl) return;
2141
+ statusEl.textContent = err ? 'Error: '+err : 'Saved';
2142
+ statusEl.style.color = err ? 'var(--red)' : 'var(--green)';
2143
+ if (!err) setTimeout(function(){ statusEl.textContent=''; }, 2000);
2144
+ });
2145
+ }, 1200);
2146
+ });
2147
+ }
2148
+ }
2149
+ }
2150
+
2151
+ function initKanban(kbEl, bidx) {
2152
+ var dragging = null;
2153
+ kbEl.querySelectorAll('.k-card[data-line]').forEach(function(card) {
2154
+ card.setAttribute('draggable', 'true');
2155
+ card.addEventListener('click', function() {
2156
+ if (card.classList.contains('dragging')) return;
2157
+ var task = findTask(bidx, parseInt(card.getAttribute('data-line')));
2158
+ if (task) { task.bidx = bidx; openTaskEdit(task); }
2159
+ });
2160
+ card.addEventListener('dragstart', function(e) {
2161
+ dragging = card; card.classList.add('dragging');
2162
+ e.dataTransfer.effectAllowed = 'move';
2163
+ e.dataTransfer.setData('text/plain', card.getAttribute('data-line'));
2164
+ });
2165
+ card.addEventListener('dragend', function() {
2166
+ card.classList.remove('dragging'); dragging = null;
2167
+ });
2168
+ });
2169
+ kbEl.querySelectorAll('.k-col[data-status]').forEach(function(col) {
2170
+ var newStatus = col.getAttribute('data-status');
2171
+ col.addEventListener('dragover', function(e) { e.preventDefault(); col.classList.add('drag-over'); });
2172
+ col.addEventListener('dragleave', function() { col.classList.remove('drag-over'); });
2173
+ col.addEventListener('drop', function(e) {
2174
+ e.preventDefault(); col.classList.remove('drag-over');
2175
+ var tline = parseInt(e.dataTransfer.getData('text/plain') || '0');
2176
+ var task = findTask(bidx, tline);
2177
+ if (!task || task.status === newStatus) return;
2178
+ var yblocks = (state.blocks||[]).filter(function(b){return b.kind==='yatt';});
2179
+ var block = yblocks[bidx];
2180
+ if (!block||!block.source) return;
2181
+ // Update in memory immediately
2182
+ task.status = newStatus;
2183
+ var updated = {}; for (var k in task) updated[k]=task[k];
2184
+ var newSource = patchBlockSource(block.source, task.line, serializeTaskLine(updated));
2185
+ block.source = newSource;
2186
+ // Rebuild kanban in-place so we stay on the kanban tab
2187
+ kbEl.innerHTML = buildKanbanHtml(block.tasks || []);
2188
+ initKanban(kbEl, bidx);
2189
+ saveBlock(bidx, newSource, null);
2190
+ });
2191
+ });
2192
+ }
2193
+
2194
+ function esc(s) {
2195
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
2196
+ }
2197
+ function sdot(status) {
2198
+ var c = STATUS_COLOR[status] || '#7d8590';
2199
+ return '<span class="sdot" style="background:' + c + '" title="' + esc(status) + '"></span>';
2200
+ }
2201
+ function shiftBadges(task) {
2202
+ var html = '';
2203
+ var mods = task.modifiers || [];
2204
+ mods.forEach(function(m) {
2205
+ var dm = m.match(/^(delayed|blocked):(.+)$/);
2206
+ if (dm) html += '<span class="k-shift-badge ' + dm[1] + '" title="' + dm[1] + ' ' + dm[2] + '">' +
2207
+ (dm[1] === 'delayed' ? '\u23F1' : '\u{1F6AB}') + ' ' + dm[2] + '</span>';
2208
+ });
2209
+ return html;
2210
+ }
2211
+ function avatarEl(name, large) {
2212
+ var clean = name.replace(/[^a-zA-Z]/g,'');
2213
+ var init = (clean.slice(0,2) || name.slice(0,2)).toUpperCase();
2214
+ return '<span class="' + (large ? 'avatar avatar-lg' : 'avatar') + '" title="@' + esc(name) + '">' + esc(init) + '</span>';
2215
+ }
2216
+
2217
+ var _ghcTask = null;
2218
+ function showGanttHoverCard(task, mx, my) {
2219
+ var card = document.getElementById('gantt-hover-card');
2220
+ if (!card) return;
2221
+ if (_ghcTask === task) {
2222
+ // Just reposition
2223
+ positionGhc(card, mx, my);
2224
+ return;
2225
+ }
2226
+ _ghcTask = task;
2227
+ var STATUS_COLOR_MAP = {
2228
+ new:'#93a8c4', active:'#6a9fd8', done:'#6aab85', blocked:'#c97070',
2229
+ 'at-risk':'#c9a04a', deferred:'#a892cc', cancelled:'#8a96a6',
2230
+ review:'#8e7ec4', paused:'#7a90a6'
2231
+ };
2232
+ var dotColor = STATUS_COLOR_MAP[task.status] || '#94a3b8';
2233
+ var html = '<div class="ghc-name">' + esc(task.name) + '</div>';
2234
+ html += '<div class="ghc-row ghc-status"><span class="ghc-dot" style="background:' + dotColor + '"></span>' + esc(task.status) + '</div>';
2235
+ if (task.assignees && task.assignees.length) {
2236
+ html += '<div class="ghc-row">\u{1F464} ' + task.assignees.map(function(a){ return '@'+esc(a); }).join(', ') + '</div>';
2237
+ }
2238
+ if (task.progress != null) {
2239
+ html += '<div class="ghc-row">\u25D0 ' + task.progress + '%</div>';
2240
+ }
2241
+ if (task.startDate || task.dueDate) {
2242
+ html += '<div class="ghc-row">\u{1F4C5} ' + esc(task.startDate || '?') + (task.dueDate ? ' \u2192 ' + esc(task.dueDate) : '') + '</div>';
2243
+ }
2244
+ if (task.duration) {
2245
+ html += '<div class="ghc-row">\u23F3 ' + esc(task.duration.value + task.duration.unit) + '</div>';
2246
+ }
2247
+ var mods = (task.modifiers || []);
2248
+ var delayedM = mods.find(function(m){ return m.match(/^delayed:/); });
2249
+ var blockedM = mods.find(function(m){ return m.match(/^blocked:/); });
2250
+ if (delayedM) html += '<div class="ghc-row" style="color:#c9a04a">\u23F1 delayed ' + esc(delayedM.split(':')[1]) + '</div>';
2251
+ if (blockedM) html += '<div class="ghc-row" style="color:#c97070">\u{1F6AB} blocked ' + esc(blockedM.split(':')[1]) + '</div>';
2252
+ if (task.tags && task.tags.length) {
2253
+ html += '<div class="ghc-tags">' + task.tags.map(function(t){ return '<span class="ghc-tag">#'+esc(t)+'</span>'; }).join('') + '</div>';
2254
+ }
2255
+ if (task.description) {
2256
+ html += '<div class="ghc-desc">' + esc(task.description) + '</div>';
2257
+ }
2258
+ card.innerHTML = html;
2259
+ card.style.display = 'block';
2260
+ positionGhc(card, mx, my);
2261
+ }
2262
+ function positionGhc(card, mx, my) {
2263
+ var pad = 14;
2264
+ var w = card.offsetWidth || 220;
2265
+ var h = card.offsetHeight || 120;
2266
+ var left = mx + pad;
2267
+ var top = my - h / 2;
2268
+ if (left + w > window.innerWidth - 8) left = mx - w - pad;
2269
+ if (top < 8) top = 8;
2270
+ if (top + h > window.innerHeight - 8) top = window.innerHeight - h - 8;
2271
+ card.style.left = left + 'px';
2272
+ card.style.top = top + 'px';
2273
+ }
2274
+ function hideGanttHoverCard() {
2275
+ _ghcTask = null;
2276
+ var card = document.getElementById('gantt-hover-card');
2277
+ if (card) card.style.display = 'none';
2278
+ }
2279
+ function inlineMd(s) {
2280
+ var r = esc(s).replace(/\`([^\`]+)\`/g,'<code>$1</code>');
2281
+ return r
2282
+ .replace(/\\*\\*(.+?)\\*\\*/g,'<strong>$1</strong>')
2283
+ .replace(/__(.+?)__/g,'<strong>$1</strong>')
2284
+ .replace(/\\*(.+?)\\*/g,'<em>$1</em>')
2285
+ .replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g,'<a href="$2" target="_blank">$1</a>');
2286
+ }
2287
+ function isTableRow(s) { return s.trim().startsWith('|') && s.trim().endsWith('|'); }
2288
+ function isSepRow(s) { return /^\\|[-|: ]+\\|$/.test(s.trim()); }
2289
+ function renderTblRow(s, tag) {
2290
+ return '<tr>' + s.trim().slice(1,-1).split('|').map(function(c) {
2291
+ return '<' + tag + '>' + inlineMd(c.trim()) + '</' + tag + '>';
2292
+ }).join('') + '</tr>';
2293
+ }
2294
+ function simpleMarkdown(md) {
2295
+ var lines = md.split('\\n'), out = [], inList = false, inTable = false, tblHead = false;
2296
+ var inCode = false, codeLang = '', codeBuf = [];
2297
+ for (var i = 0; i < lines.length; i++) {
2298
+ var raw = lines[i];
2299
+ // fenced code blocks
2300
+ if (inCode) {
2301
+ if (raw.trimEnd() === '\`\`\`' || raw.trimEnd() === '~~~') {
2302
+ out.push('<pre><code' + (codeLang ? ' class="lang-' + esc(codeLang) + '"' : '') + '>' +
2303
+ codeBuf.map(function(l) { return esc(l); }).join('\\n') + '</code></pre>');
2304
+ codeBuf = []; inCode = false; codeLang = '';
2305
+ } else { codeBuf.push(raw); }
2306
+ continue;
2307
+ }
2308
+ var fence = raw.match(/^(\`\`\`|~~~)(\\w*)\\s*$/);
2309
+ if (fence) {
2310
+ if (inList) { out.push('</ul>'); inList = false; }
2311
+ if (inTable) { out.push('</tbody></table>'); inTable = false; }
2312
+ inCode = true; codeLang = fence[2] || ''; continue;
2313
+ }
2314
+ // tables
2315
+ if (isTableRow(raw)) {
2316
+ if (!inTable) {
2317
+ if (inList) { out.push('</ul>'); inList = false; }
2318
+ out.push('<table>'); inTable = true; tblHead = true;
2319
+ }
2320
+ if (isSepRow(raw)) { tblHead = false; out.push('<tbody>'); continue; }
2321
+ if (tblHead) out.push('<thead>' + renderTblRow(raw,'th') + '</thead>');
2322
+ else out.push(renderTblRow(raw,'td'));
2323
+ continue;
2324
+ }
2325
+ if (inTable) { out.push('</tbody></table>'); inTable = false; }
2326
+ if (raw.trim() === '') { if (inList) { out.push('</ul>'); inList = false; } continue; }
2327
+ var hm = raw.match(/^(#{1,6})\\s+(.+)$/);
2328
+ if (hm) { if (inList) { out.push('</ul>'); inList = false; }
2329
+ out.push('<h'+hm[1].length+'>'+inlineMd(hm[2])+'</h'+hm[1].length+'>'); continue; }
2330
+ var bq = raw.match(/^>\\s*(.*)/);
2331
+ if (bq) { if (inList) { out.push('</ul>'); inList = false; }
2332
+ out.push('<blockquote>'+inlineMd(bq[1])+'</blockquote>'); continue; }
2333
+ var li = raw.match(/^[-*+]\\s+(.+)$/);
2334
+ if (li) { if (!inList) { out.push('<ul>'); inList = true; }
2335
+ out.push('<li>'+inlineMd(li[1])+'</li>'); continue; }
2336
+ if (inList) { out.push('</ul>'); inList = false; }
2337
+ if (raw.trim() === '---' || raw.trim() === '***') { out.push('<hr>'); continue; }
2338
+ out.push('<p>'+inlineMd(raw)+'</p>');
2339
+ }
2340
+ if (inList) out.push('</ul>');
2341
+ if (inTable) out.push('</tbody></table>');
2342
+ if (inCode) out.push('<pre><code>' + codeBuf.map(function(l){return esc(l);}).join('\\n') + '</code></pre>');
2343
+ return out.join('\\n');
2344
+ }
2345
+
2346
+ // \u2500\u2500 Yatt control (per-block independent widget) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2347
+
2348
+ // Called from onclick attributes injected into innerHTML \u2014 must be top-level
2349
+ function yattCtrlSetView(ctrlId, panel) {
2350
+ ctrlViews[ctrlId] = panel;
2351
+ var ctrl = document.getElementById(ctrlId);
2352
+ if (!ctrl) return;
2353
+ ctrl.querySelectorAll('.yatt-ctrl-tab').forEach(function(btn) {
2354
+ btn.classList.toggle('active', btn.getAttribute('data-panel') === panel);
2355
+ });
2356
+ ctrl.querySelectorAll('.yatt-ctrl-panel').forEach(function(p) {
2357
+ p.classList.toggle('active', p.getAttribute('data-panel') === panel);
2358
+ });
2359
+ }
2360
+
2361
+ function buildKanbanHtml(tasks) {
2362
+ var byStatus = {};
2363
+ KANBAN_COLS.forEach(function(s) { byStatus[s] = []; });
2364
+ (tasks || []).forEach(function(t) {
2365
+ var s = t.status || 'new';
2366
+ if (!byStatus[s]) byStatus[s] = [];
2367
+ byStatus[s].push(t);
2368
+ });
2369
+ var html = '';
2370
+ KANBAN_COLS.forEach(function(status) {
2371
+ var cards = byStatus[status] || [];
2372
+ var color = STATUS_COLOR[status] || '#7d8590';
2373
+ var label = STATUS_LABEL[status] || status;
2374
+ var isEmpty = cards.length === 0;
2375
+ html += '<div class="k-col' + (isEmpty ? ' empty' : '') + '" data-status="' + esc(status) + '">';
2376
+ if (isEmpty) {
2377
+ html += '<div class="k-col-empty-label">';
2378
+ html += '<span class="k-col-empty-line" style="background:' + color + '"></span>';
2379
+ html += '<span class="k-col-empty-title" style="color:' + color + '">' + esc(label) + '</span>';
2380
+ html += '</div>';
2381
+ } else {
2382
+ html += '<div class="k-col-header">';
2383
+ html += '<span class="k-col-line" style="background:' + color + '"></span>';
2384
+ html += '<span class="k-col-title">' + esc(label) + '</span>';
2385
+ html += '<span class="k-col-count">' + cards.length + '</span>';
2386
+ html += '</div>';
2387
+ }
2388
+ html += '<div class="k-cards">';
2389
+ cards.forEach(function(t) {
2390
+ var indent = t.depth > 0 ? 'padding-left:' + (8 + t.depth * 10) + 'px;' : '';
2391
+ var lineAttr = t.line ? ' data-line="' + t.line + '"' : '';
2392
+ html += '<div class="k-card" style="' + indent + '"' + lineAttr + '>';
2393
+ html += '<div class="k-card-name">' + esc(t.name) + '</div>';
2394
+ if (t.description) {
2395
+ html += '<div class="k-card-desc">' + esc(t.description) + '</div>';
2396
+ }
2397
+ var sb = shiftBadges(t);
2398
+ if (sb) html += '<div class="k-card-shifts">' + sb + '</div>';
2399
+ html += '<div class="k-card-meta">';
2400
+ if (t.assignees && t.assignees.length) {
2401
+ t.assignees.slice(0,3).forEach(function(a) { html += avatarEl(a, false); });
2402
+ }
2403
+ if (t.priority && t.priority !== 'normal') {
2404
+ html += '<span class="k-priority" data-p="' + esc(t.priority) + '">' + esc(t.priority) + '</span>';
2405
+ }
2406
+ html += '</div>';
2407
+ if (t.progress != null) {
2408
+ html += '<div class="k-progress"><div class="k-progress-fill" style="width:' + t.progress + '%"></div></div>';
2409
+ }
2410
+ html += '</div>';
2411
+ });
2412
+ html += '</div></div>';
2413
+ });
2414
+ if (!html) html = '<div class="loading" style="padding:20px">No tasks.</div>';
2415
+ return html;
2416
+ }
2417
+
2418
+ function buildPeopleHtml(tasks) {
2419
+ var byPerson = {};
2420
+ (tasks || []).forEach(function(t) {
2421
+ var people = t.assignees && t.assignees.length ? t.assignees : ['(unassigned)'];
2422
+ people.forEach(function(p) {
2423
+ if (!byPerson[p]) byPerson[p] = [];
2424
+ byPerson[p].push(t);
2425
+ });
2426
+ });
2427
+ var names = Object.keys(byPerson).sort(function(a,b) {
2428
+ if (a==='(unassigned)') return 1; if (b==='(unassigned)') return -1;
2429
+ return a.localeCompare(b);
2430
+ });
2431
+ if (!names.length) return '<div class="loading" style="padding:20px">No tasks.</div>';
2432
+ var html = '';
2433
+ names.forEach(function(name) {
2434
+ var list = byPerson[name];
2435
+ var isU = name === '(unassigned)';
2436
+ html += '<div class="person-card"><div class="person-header">';
2437
+ if (!isU) html += avatarEl(name, true);
2438
+ html += '<div><div class="person-name">' + esc(isU ? 'Unassigned' : '@' + name) + '</div>';
2439
+ html += '<div class="person-count">' + list.length + ' task' + (list.length===1?'':'s') + '</div></div></div>';
2440
+ list.forEach(function(t) {
2441
+ var lineAttr = t.line ? ' data-line="' + t.line + '"' : '';
2442
+ html += '<div class="ptask-row"' + lineAttr + '>' + sdot(t.status);
2443
+ html += '<span class="ptask-name">' + esc(t.name) + '</span>';
2444
+ var sb = shiftBadges(t);
2445
+ if (sb) html += sb;
2446
+ if (t.priority && t.priority !== 'normal')
2447
+ html += '<span class="ptask-priority" data-p="' + esc(t.priority) + '">' + esc(t.priority) + '</span>';
2448
+ if (t.progress != null) html += '<span class="ptask-prog">' + t.progress + '%</span>';
2449
+ html += '</div>';
2450
+ });
2451
+ html += '</div>';
2452
+ });
2453
+ return html;
2454
+ }
2455
+
2456
+ function buildYattCtrlHtml(block, ctrlId, bidx) {
2457
+ var errHtml = block.errors && block.errors.length
2458
+ ? '<div class="yatt-errors">' + block.errors.map(esc).join('<br>') + '</div>' : '';
2459
+ var tl = '<div class="yatt-ctrl-panel active" data-panel="timeline">' + (block.html || '') + '</div>';
2460
+ var kb = '<div class="yatt-ctrl-panel" data-panel="kanban"><div class="ctrl-kanban">' +
2461
+ buildKanbanHtml(block.tasks || []) + '</div></div>';
2462
+ var pe = '<div class="yatt-ctrl-panel" data-panel="people"><div class="ctrl-people">' +
2463
+ buildPeopleHtml(block.tasks || []) + '</div></div>';
2464
+ var md = '<div class="yatt-ctrl-panel" data-panel="markdown">' +
2465
+ '<textarea class="yatt-block-editor" spellcheck="false">' + esc(block.source || '') + '</textarea>' +
2466
+ '<div class="yatt-block-bar"><span class="yatt-block-status"></span></div>' +
2467
+ '</div>';
2468
+ var tabs =
2469
+ '<button class="yatt-ctrl-tab active" data-panel="timeline" onclick="yattCtrlSetView(\\'' + ctrlId + '\\',\\'timeline\\')">Timeline</button>' +
2470
+ '<button class="yatt-ctrl-tab" data-panel="kanban" onclick="yattCtrlSetView(\\'' + ctrlId + '\\',\\'kanban\\')">Kanban</button>' +
2471
+ '<button class="yatt-ctrl-tab" data-panel="people" onclick="yattCtrlSetView(\\'' + ctrlId + '\\',\\'people\\')">People</button>' +
2472
+ '<button class="yatt-ctrl-tab" data-panel="markdown" onclick="yattCtrlSetView(\\'' + ctrlId + '\\',\\'markdown\\')">Edit</button>';
2473
+ return '<div class="yatt-ctrl" id="' + ctrlId + '" data-bidx="' + bidx + '">' +
2474
+ errHtml +
2475
+ '<div class="yatt-ctrl-bar">' + tabs + '</div>' +
2476
+ '<div class="yatt-ctrl-body">' + tl + kb + pe + md + '</div>' +
2477
+ '</div>';
2478
+ }
2479
+
2480
+ // \u2500\u2500 Document view \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2481
+
2482
+ function renderViewPanel() {
2483
+ var el = document.getElementById('doc-content');
2484
+ if (!el) return;
2485
+ if (state.blocks) { buildDocument(state.blocks, el); return; }
2486
+ el.innerHTML = '<div class="loading">Loading\u2026</div>';
2487
+ fetch('/api/render?p=' + encodeURIComponent(state.currentFile))
2488
+ .then(function(r) { return r.json(); })
2489
+ .then(function(d) { state.blocks = d.blocks || []; buildDocument(state.blocks, el); })
2490
+ .catch(function(e) { el.innerHTML = '<div class="loading err">' + esc(e.message) + '</div>'; });
2491
+ }
2492
+
2493
+ function buildDocument(blocks, container) {
2494
+ if (!blocks || !blocks.length) {
2495
+ container.innerHTML = '<div class="loading">No content.</div>'; return;
2496
+ }
2497
+ var html = '', ctrlIdx = 0, ctrlList = [];
2498
+ blocks.forEach(function(b) {
2499
+ if (b.kind === 'heading') {
2500
+ html += '<div class="block-h' + b.level + '">' + inlineMd(b.text) + '</div>';
2501
+ } else if (b.kind === 'prose') {
2502
+ html += '<div class="prose">' + simpleMarkdown(b.text) + '</div>';
2503
+ } else if (b.kind === 'yatt') {
2504
+ var bidx = ctrlIdx++;
2505
+ html += buildYattCtrlHtml(b, 'yatt-' + bidx, bidx);
2506
+ ctrlList.push(bidx);
2507
+ }
2508
+ });
2509
+ container.innerHTML = html;
2510
+ ctrlList.forEach(function(bidx) {
2511
+ var ctrlId = 'yatt-' + bidx;
2512
+ initYattCtrl(ctrlId, bidx);
2513
+ var saved = ctrlViews[ctrlId];
2514
+ if (saved && saved !== 'timeline') yattCtrlSetView(ctrlId, saved);
2515
+ });
2516
+ }
2517
+
2518
+ // \u2500\u2500 Markdown/edit view \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2519
+
2520
+ function renderMarkdownView() {
2521
+ var ta = document.getElementById('editor');
2522
+ if (!ta) return;
2523
+ if (state.source !== null) { ta.value = state.source; return; }
2524
+ fetch('/api/source?p=' + encodeURIComponent(state.currentFile))
2525
+ .then(function(r) { return r.text(); })
2526
+ .then(function(src) { state.source = src; ta.value = src; })
2527
+ .catch(function(e) { ta.value = '// Error: ' + e.message; });
2528
+ }
2529
+
2530
+ function doSave() {
2531
+ if (!state.currentFile) return;
2532
+ var ta = document.getElementById('editor');
2533
+ if (!ta) return;
2534
+ var src = ta.value; state.source = src;
2535
+ setSaveStatus('unsaved', 'Saving\u2026');
2536
+ fetch('/api/save?p=' + encodeURIComponent(state.currentFile), {
2537
+ method: 'POST', headers: { 'Content-Type': 'text/plain; charset=utf-8' }, body: src
2538
+ })
2539
+ .then(function(r) {
2540
+ if (r.ok) setSaveStatus('saved', 'Saved');
2541
+ else r.json().then(function(d) { setSaveStatus('error', d.error || ('HTTP ' + r.status)); });
2542
+ })
2543
+ .catch(function(e) { setSaveStatus('error', e.message); });
2544
+ }
2545
+
2546
+ function setSaveStatus(cls, text) {
2547
+ var el = document.getElementById('save-status');
2548
+ if (!el) return;
2549
+ el.className = cls; el.textContent = text;
2550
+ if (cls === 'saved') setTimeout(function() {
2551
+ if (el.textContent === 'Saved') { el.textContent = ''; el.className = ''; }
2552
+ }, 2500);
2553
+ }
2554
+
2555
+ // \u2500\u2500 Page-level view switching \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2556
+
2557
+ function setView(v) {
2558
+ if (state.saveTimer && v !== 'markdown') {
2559
+ clearTimeout(state.saveTimer); state.saveTimer = null; doSave();
2560
+ }
2561
+ state.view = v;
2562
+ var url = new URL(window.location.href);
2563
+ url.searchParams.set('v', v);
2564
+ history.replaceState(history.state, '', url.toString());
2565
+ document.querySelectorAll('.tab').forEach(function(btn) {
2566
+ btn.classList.toggle('active', btn.getAttribute('data-view') === v);
2567
+ });
2568
+ ['view','markdown'].forEach(function(n) {
2569
+ var el = document.getElementById('view-' + n);
2570
+ if (el) el.hidden = (n !== v);
2571
+ });
2572
+ renderCurrentView();
2573
+ }
2574
+
2575
+ function renderCurrentView() {
2576
+ if (!state.currentFile) return;
2577
+ if (state.view === 'view') renderViewPanel();
2578
+ else if (state.view === 'markdown') renderMarkdownView();
2579
+ }
2580
+
2581
+ // \u2500\u2500 Sidebar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2582
+
2583
+ function buildSidebar(files) {
2584
+ state.files = files;
2585
+ var groups = {};
2586
+ files.forEach(function(f) {
2587
+ var parts = f.split('/');
2588
+ var dir = parts.length > 1 ? parts.slice(0,-1).join('/') : '';
2589
+ if (!groups[dir]) groups[dir] = [];
2590
+ groups[dir].push(f);
2591
+ });
2592
+ var keys = Object.keys(groups).sort(function(a,b) {
2593
+ if (a==='') return -1; if (b==='') return 1; return a.localeCompare(b);
2594
+ });
2595
+ var html = '';
2596
+ keys.forEach(function(dir) {
2597
+ html += '<div class="group-label">' + esc(dir===''?'(root)':dir+'/') + '</div>';
2598
+ groups[dir].forEach(function(f) {
2599
+ var name = f.split('/').pop();
2600
+ html += '<a class="file-item' + (f===state.currentFile?' active':'') + '" data-p="' + esc(f) + '" title="' + esc(f) + '">' + esc(name) + '</a>';
2601
+ });
2602
+ });
2603
+ document.getElementById('sidebar-inner').innerHTML = html;
2604
+ var fc = document.getElementById('file-count');
2605
+ if (fc) fc.textContent = files.length + ' file' + (files.length===1?'':'s');
2606
+ document.querySelectorAll('.file-item').forEach(function(el) {
2607
+ el.addEventListener('click', function() { navigateTo(el.getAttribute('data-p')); });
2608
+ });
2609
+ }
2610
+
2611
+ // \u2500\u2500 Navigation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2612
+
2613
+ function loadFile(p) {
2614
+ state.currentFile = p; state.blocks = null; state.source = null;
2615
+ document.querySelectorAll('.file-item').forEach(function(el) {
2616
+ el.classList.toggle('active', el.getAttribute('data-p') === p);
2617
+ });
2618
+ renderCurrentView();
2619
+ }
2620
+
2621
+ function loadFileList() {
2622
+ return fetch('/api/files')
2623
+ .then(function(r) { return r.json(); })
2624
+ .then(function(files) { buildSidebar(files); return files; });
2625
+ }
2626
+
2627
+ function navigateTo(p) {
2628
+ var u = new URL(window.location.href);
2629
+ u.searchParams.set('p', p);
2630
+ history.pushState({ p: p }, '', u.toString());
2631
+ loadFile(p);
2632
+ }
2633
+
2634
+ // \u2500\u2500 SSE live reload \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2635
+
2636
+ function connectSse() {
2637
+ var es = new EventSource('/events');
2638
+ var debounce = null;
2639
+ es.onmessage = function(e) {
2640
+ if (e.data === 'reload') {
2641
+ clearTimeout(debounce);
2642
+ debounce = setTimeout(function() {
2643
+ loadFileList().then(function() {
2644
+ if (state.view === 'markdown') return;
2645
+ if (state.currentFile) { state.blocks = null; renderCurrentView(); }
2646
+ });
2647
+ }, 250);
2648
+ }
2649
+ };
2650
+ es.onerror = function() { es.close(); setTimeout(connectSse, 2000); };
2651
+ }
2652
+
2653
+ // \u2500\u2500 Init \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2654
+
2655
+ window.addEventListener('DOMContentLoaded', function() {
2656
+ var fp = document.getElementById('folder-path');
2657
+ if (fp && typeof ROOT_FOLDER !== 'undefined') fp.textContent = ROOT_FOLDER;
2658
+
2659
+ document.querySelectorAll('.tab').forEach(function(btn) {
2660
+ btn.addEventListener('click', function() { setView(btn.getAttribute('data-view')); });
2661
+ });
2662
+
2663
+ var editor = document.getElementById('editor');
2664
+ if (editor) {
2665
+ editor.addEventListener('input', function() {
2666
+ state.source = editor.value;
2667
+ if (state.saveTimer) clearTimeout(state.saveTimer);
2668
+ setSaveStatus('unsaved', 'Unsaved');
2669
+ state.saveTimer = setTimeout(function() { state.saveTimer = null; doSave(); }, 1200);
2670
+ });
2671
+ }
2672
+
2673
+ var params = new URLSearchParams(window.location.search);
2674
+ var initial = params.get('p');
2675
+ var initialView = params.get('v') || 'view';
2676
+
2677
+ state.view = initialView;
2678
+ document.querySelectorAll('.tab').forEach(function(btn) {
2679
+ btn.classList.toggle('active', btn.getAttribute('data-view') === initialView);
2680
+ });
2681
+ ['view','markdown'].forEach(function(n) {
2682
+ var el = document.getElementById('view-' + n);
2683
+ if (el) el.hidden = (n !== initialView);
2684
+ });
2685
+
2686
+ // Task edit overlay: close on backdrop click or Escape
2687
+ var overlay = document.getElementById('task-edit-overlay');
2688
+ if (overlay) {
2689
+ overlay.addEventListener('click', function(e) {
2690
+ if (e.target === overlay) closeTaskEdit();
2691
+ });
2692
+ }
2693
+ document.addEventListener('keydown', function(e) {
2694
+ if (e.key === 'Escape') closeTaskEdit();
2695
+ });
2696
+
2697
+ // Popup form: submit on Enter in inputs (not textarea)
2698
+ document.addEventListener('keydown', function(e) {
2699
+ if (e.key === 'Enter' && state.editTask) {
2700
+ var tag = (e.target && e.target.tagName) ? e.target.tagName.toLowerCase() : '';
2701
+ if (tag === 'input' || tag === 'select') { e.preventDefault(); saveTaskEdit(); }
2702
+ }
2703
+ });
2704
+
2705
+ loadFileList().then(function(files) {
2706
+ if (initial && files.includes(initial)) loadFile(initial);
2707
+ else if (files.length > 0) navigateTo(files[0]);
2708
+ else {
2709
+ var c = document.getElementById('doc-content');
2710
+ if (c) c.innerHTML = '<div class="loading">No .md files found.</div>';
2711
+ }
2712
+ });
2713
+
2714
+ connectSse();
2715
+ });
2716
+
2717
+ window.addEventListener('popstate', function(e) {
2718
+ if (e.state && e.state.p) loadFile(e.state.p);
2719
+ });
2720
+
2721
+ // \u2500\u2500 Git integration \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2722
+
2723
+ var gitState = { available: false, dirty: false, ahead: 0, behind: 0, branch: '', hasRemote: false };
2724
+ var gitBusy = false;
2725
+
2726
+ function refreshGitStatus() {
2727
+ fetch('/api/git/status').then(function(r){ return r.json(); }).then(function(s) {
2728
+ gitState = s;
2729
+ renderGitWidget();
2730
+ }).catch(function(){});
2731
+ }
2732
+
2733
+ function renderGitWidget() {
2734
+ var w = document.getElementById('git-widget');
2735
+ if (!w) return;
2736
+ if (!gitState.available) { w.classList.add('hidden'); return; }
2737
+ w.classList.remove('hidden');
2738
+
2739
+ document.getElementById('git-dot').className = 'git-dot' + (gitState.dirty ? ' dirty' : '');
2740
+ document.getElementById('git-branch-name').textContent = gitState.branch;
2741
+
2742
+ var behindEl = document.getElementById('git-behind');
2743
+ var aheadEl = document.getElementById('git-ahead');
2744
+ if (gitState.behind > 0) {
2745
+ document.getElementById('git-behind-n').textContent = gitState.behind;
2746
+ behindEl.classList.remove('hidden');
2747
+ } else { behindEl.classList.add('hidden'); }
2748
+ if (gitState.ahead > 0) {
2749
+ document.getElementById('git-ahead-n').textContent = gitState.ahead;
2750
+ aheadEl.classList.remove('hidden');
2751
+ } else { aheadEl.classList.add('hidden'); }
2752
+
2753
+ document.getElementById('git-pull-btn').style.display = gitState.hasRemote ? '' : 'none';
2754
+ document.getElementById('git-push-btn').style.display = gitState.hasRemote ? '' : 'none';
2755
+ document.getElementById('git-commit-btn').disabled = gitBusy;
2756
+ document.getElementById('git-pull-btn').disabled = gitBusy;
2757
+ document.getElementById('git-push-btn').disabled = gitBusy;
2758
+ }
2759
+
2760
+ function setGitMsg(msg, type) {
2761
+ var el = document.getElementById('git-msg');
2762
+ el.textContent = msg;
2763
+ el.className = type === 'ok' ? 'ok' : type === 'err' ? 'err' : '';
2764
+ if (msg && type !== 'err') setTimeout(function(){ if (el.textContent === msg) el.textContent = ''; }, 4000);
2765
+ }
2766
+
2767
+ function gitDoPull() {
2768
+ if (gitBusy) return;
2769
+ gitBusy = true; renderGitWidget(); setGitMsg('Pulling\u2026', '');
2770
+ fetch('/api/git/pull', { method: 'POST' }).then(function(r){ return r.json(); }).then(function(r) {
2771
+ gitBusy = false;
2772
+ if (r.conflict) { setGitMsg('Merge conflict \u2014 resolve from CLI', 'err'); }
2773
+ else if (!r.ok) { setGitMsg('Pull failed \u2014 ' + (r.output || r.error || '').slice(0, 60), 'err'); }
2774
+ else { setGitMsg('Pulled \u2713', 'ok'); }
2775
+ refreshGitStatus();
2776
+ }).catch(function(e){ gitBusy = false; setGitMsg('Pull error', 'err'); refreshGitStatus(); });
2777
+ }
2778
+
2779
+ function gitDoPush() {
2780
+ if (gitBusy) return;
2781
+ gitBusy = true; renderGitWidget(); setGitMsg('Pushing\u2026', '');
2782
+ fetch('/api/git/push', { method: 'POST' }).then(function(r){ return r.json(); }).then(function(r) {
2783
+ gitBusy = false;
2784
+ if (r.rejected) { setGitMsg('Push rejected \u2014 pull first or use CLI', 'err'); }
2785
+ else if (!r.ok) { setGitMsg('Push failed \u2014 ' + (r.output || r.error || '').slice(0, 60), 'err'); }
2786
+ else { setGitMsg('Pushed \u2713', 'ok'); }
2787
+ refreshGitStatus();
2788
+ }).catch(function(e){ gitBusy = false; setGitMsg('Push error', 'err'); refreshGitStatus(); });
2789
+ }
2790
+
2791
+ function openCommitModal() {
2792
+ var now = new Date();
2793
+ var ts = now.getFullYear() + '-' +
2794
+ String(now.getMonth()+1).padStart(2,'0') + '-' +
2795
+ String(now.getDate()).padStart(2,'0') + ' ' +
2796
+ String(now.getHours()).padStart(2,'0') + ':' +
2797
+ String(now.getMinutes()).padStart(2,'0');
2798
+ document.getElementById('git-commit-msg').value = 'Update tasks ' + ts;
2799
+ document.getElementById('git-commit-modal').classList.remove('hidden');
2800
+ setTimeout(function(){ document.getElementById('git-commit-msg').focus(); }, 50);
2801
+ }
2802
+
2803
+ function closeCommitModal() {
2804
+ document.getElementById('git-commit-modal').classList.add('hidden');
2805
+ }
2806
+
2807
+ function gitDoCommit() {
2808
+ var msg = document.getElementById('git-commit-msg').value.trim();
2809
+ if (!msg) return;
2810
+ closeCommitModal();
2811
+ gitBusy = true; renderGitWidget(); setGitMsg('Committing\u2026', '');
2812
+ fetch('/api/git/commit', {
2813
+ method: 'POST',
2814
+ headers: { 'Content-Type': 'application/json' },
2815
+ body: JSON.stringify({ message: msg })
2816
+ }).then(function(r){ return r.json(); }).then(function(r) {
2817
+ gitBusy = false;
2818
+ if (r.noop) { setGitMsg('Nothing to commit', ''); }
2819
+ else if (!r.ok) { setGitMsg('Commit failed \u2014 ' + (r.error || r.output || '').slice(0, 60), 'err'); }
2820
+ else { setGitMsg('Committed \u2713', 'ok'); }
2821
+ refreshGitStatus();
2822
+ }).catch(function(e){ gitBusy = false; setGitMsg('Commit error', 'err'); refreshGitStatus(); });
2823
+ }
2824
+
2825
+ // Initial status fetch + poll every 30s
2826
+ refreshGitStatus();
2827
+ setInterval(refreshGitStatus, 30000);
2828
+ `;
2829
+ function buildShellHtml(rootDir) {
2830
+ return `<!DOCTYPE html>
2831
+ <html lang="en">
2832
+ <head>
2833
+ <meta charset="UTF-8">
2834
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
2835
+ <title>YATT Viewer</title>
2836
+ <style>${CSS}</style>
2837
+ </head>
2838
+ <body>
2839
+ <div id="app">
2840
+ <header id="topbar">
2841
+ <span class="logo">YATT</span>
2842
+ <span id="folder-path"></span>
2843
+ <div id="view-tabs">
2844
+ <button class="tab active" data-view="view">View</button>
2845
+ <button class="tab" data-view="markdown">Markdown</button>
2846
+ </div>
2847
+ <span id="save-status"></span>
2848
+ <span id="file-count" style="font-size:11px;color:var(--muted);flex-shrink:0"></span>
2849
+ <div id="git-widget" class="hidden">
2850
+ <span id="git-msg"></span>
2851
+ <span class="git-count behind hidden" id="git-behind" title="commits behind remote">\u2193<span id="git-behind-n"></span></span>
2852
+ <span class="git-branch"><span class="git-dot" id="git-dot"></span><span id="git-branch-name"></span></span>
2853
+ <span class="git-count ahead hidden" id="git-ahead" title="commits ahead of remote">\u2191<span id="git-ahead-n"></span></span>
2854
+ <button class="git-btn" id="git-pull-btn" onclick="gitDoPull()" title="Pull latest from remote">Pull</button>
2855
+ <button class="git-btn primary" id="git-commit-btn" onclick="openCommitModal()" title="Commit all changes">Commit</button>
2856
+ <button class="git-btn" id="git-push-btn" onclick="gitDoPush()" title="Push to remote">Push</button>
2857
+ </div>
2858
+ </header>
2859
+ <div id="workspace">
2860
+ <nav id="sidebar"><div id="sidebar-inner"></div></nav>
2861
+ <main id="main">
2862
+ <div id="view-view" class="view-panel"><div id="doc-content"></div></div>
2863
+ <div id="view-markdown" class="view-panel" hidden><textarea id="editor" spellcheck="false"></textarea></div>
2864
+ </main>
2865
+ </div>
2866
+ </div>
2867
+
2868
+ <div id="git-commit-modal" class="hidden">
2869
+ <div id="git-commit-box">
2870
+ <h3>Commit changes</h3>
2871
+ <textarea id="git-commit-msg" placeholder="Commit message"></textarea>
2872
+ <div class="git-commit-actions">
2873
+ <button class="te-btn te-btn-ghost" onclick="closeCommitModal()">Cancel</button>
2874
+ <button class="te-btn te-btn-primary" onclick="gitDoCommit()">Commit</button>
2875
+ </div>
2876
+ </div>
2877
+ </div>
2878
+ <div id="gantt-hover-card"></div>
2879
+ <div id="task-edit-overlay" class="hidden">
2880
+ <div id="task-edit-modal">
2881
+ <div class="te-title">Edit Task</div>
2882
+ <div class="te-row">
2883
+ <div class="te-field"><label>Name</label><input type="text" id="te-name" placeholder="Task name"></div>
2884
+ </div>
2885
+ <div class="te-row cols-2">
2886
+ <div class="te-field"><label>Status</label>
2887
+ <select id="te-status">
2888
+ <option value="new">New</option>
2889
+ <option value="active">Active</option>
2890
+ <option value="review">Review</option>
2891
+ <option value="blocked">Blocked</option>
2892
+ <option value="paused">Paused</option>
2893
+ <option value="done">Done</option>
2894
+ <option value="cancelled">Cancelled</option>
2895
+ </select>
2896
+ </div>
2897
+ <div class="te-field"><label>Priority</label>
2898
+ <select id="te-priority">
2899
+ <option value="low">Low</option>
2900
+ <option value="normal">Normal</option>
2901
+ <option value="high">High</option>
2902
+ <option value="critical">Critical</option>
2903
+ </select>
2904
+ </div>
2905
+ </div>
2906
+ <div class="te-row cols-2">
2907
+ <div class="te-field"><label>Assignees (space-separated)</label><input type="text" id="te-assignees" placeholder="@alice @bob"></div>
2908
+ <div class="te-field"><label>Tags (space-separated)</label><input type="text" id="te-tags" placeholder="#backend #api"></div>
2909
+ </div>
2910
+ <div class="te-row cols-3">
2911
+ <div class="te-field"><label>Duration</label><input type="text" id="te-duration" placeholder="5d, 2bd, 1w"></div>
2912
+ <div class="te-field"><label>Start Date</label><input type="text" id="te-startdate" placeholder="YYYY-MM-DD"></div>
2913
+ <div class="te-field"><label>Due Date</label><input type="text" id="te-duedate" placeholder="YYYY-MM-DD"></div>
2914
+ </div>
2915
+ <div class="te-row cols-3">
2916
+ <div class="te-field"><label>Progress (%)</label><input type="number" id="te-progress" min="0" max="100" placeholder="0"></div>
2917
+ <div class="te-field"><label>ID</label><input type="text" id="te-id" placeholder="task-slug"></div>
2918
+ <div class="te-field"><label>After (deps)</label><input type="text" id="te-after" placeholder="id1,id2"></div>
2919
+ </div>
2920
+ <div class="te-row cols-2">
2921
+ <div class="te-field"><label>Delayed by</label><input type="text" id="te-delayed" placeholder="e.g. 3d, 1w"></div>
2922
+ <div class="te-field"><label>Blocked for</label><input type="text" id="te-blocked" placeholder="e.g. 2w, 5d"></div>
2923
+ </div>
2924
+ <div class="te-row">
2925
+ <div class="te-field"><label>Description</label><textarea id="te-description" placeholder="Optional description (saved as // comment lines below the task)"></textarea></div>
2926
+ </div>
2927
+ <div class="te-actions">
2928
+ <span class="te-save-msg" id="te-save-msg"></span>
2929
+ <button class="te-btn te-btn-ghost" onclick="closeTaskEdit()">Cancel</button>
2930
+ <button class="te-btn te-btn-primary" onclick="saveTaskEdit()">Save</button>
2931
+ </div>
2932
+ </div>
2933
+ </div>
2934
+ <script>var ROOT_FOLDER = ${JSON.stringify(rootDir)};
2935
+ ${JS}</script>
2936
+ </body>
2937
+ </html>`;
2938
+ }
2939
+ function createServer2(rootDir, port, sse) {
2940
+ const shellHtml = buildShellHtml(rootDir);
2941
+ return http.createServer((req, res) => {
2942
+ const parsed = new URL(req.url ?? "/", `http://localhost:${port}`);
2943
+ const pathname = parsed.pathname;
2944
+ if (pathname === "/") {
2945
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
2946
+ res.end(shellHtml);
2947
+ return;
2948
+ }
2949
+ if (pathname === "/api/files") {
2950
+ const files = walkMdFiles(rootDir).map((f) => path.relative(rootDir, f).split(path.sep).join("/"));
2951
+ res.writeHead(200, { "Content-Type": "application/json" });
2952
+ res.end(JSON.stringify(files));
2953
+ return;
2954
+ }
2955
+ if (pathname === "/api/render") {
2956
+ const absPath = guardPath(rootDir, parsed.searchParams.get("p"));
2957
+ if (!absPath) {
2958
+ res.writeHead(400, { "Content-Type": "application/json" });
2959
+ res.end('"Bad request"');
2960
+ return;
2961
+ }
2962
+ try {
2963
+ const blocks = renderFile(absPath);
2964
+ res.writeHead(200, { "Content-Type": "application/json" });
2965
+ res.end(JSON.stringify({ blocks }));
2966
+ } catch (e) {
2967
+ res.writeHead(500, { "Content-Type": "application/json" });
2968
+ res.end(JSON.stringify({ error: e.message }));
2969
+ }
2970
+ return;
2971
+ }
2972
+ if (pathname === "/api/source") {
2973
+ const absPath = guardPath(rootDir, parsed.searchParams.get("p"));
2974
+ if (!absPath) {
2975
+ res.writeHead(400);
2976
+ res.end("Bad request");
2977
+ return;
2978
+ }
2979
+ try {
2980
+ res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
2981
+ res.end(fs.readFileSync(absPath, "utf8"));
2982
+ } catch (e) {
2983
+ res.writeHead(500);
2984
+ res.end(e.message);
2985
+ }
2986
+ return;
2987
+ }
2988
+ if (pathname === "/api/save" && req.method === "POST") {
2989
+ const absPath = guardPath(rootDir, parsed.searchParams.get("p"));
2990
+ if (!absPath) {
2991
+ res.writeHead(400, { "Content-Type": "application/json" });
2992
+ res.end('"Bad request"');
2993
+ return;
2994
+ }
2995
+ const chunks = [];
2996
+ req.on("data", (chunk) => chunks.push(chunk));
2997
+ req.on("end", () => {
2998
+ try {
2999
+ fs.writeFileSync(absPath, Buffer.concat(chunks).toString("utf8"), "utf8");
3000
+ res.writeHead(200, { "Content-Type": "application/json" });
3001
+ res.end('"ok"');
3002
+ sse.broadcast("reload");
3003
+ } catch (e) {
3004
+ res.writeHead(500, { "Content-Type": "application/json" });
3005
+ res.end(JSON.stringify({ error: e.message }));
3006
+ }
3007
+ });
3008
+ req.on("error", (e) => {
3009
+ res.writeHead(500, { "Content-Type": "application/json" });
3010
+ res.end(JSON.stringify({ error: e.message }));
3011
+ });
3012
+ return;
3013
+ }
3014
+ if (pathname === "/api/save-block" && req.method === "POST") {
3015
+ const absPath = guardPath(rootDir, parsed.searchParams.get("p"));
3016
+ const blockIdx = parseInt(parsed.searchParams.get("idx") ?? "-1", 10);
3017
+ if (!absPath || blockIdx < 0) {
3018
+ res.writeHead(400, { "Content-Type": "application/json" });
3019
+ res.end(JSON.stringify({ error: "Bad request" }));
3020
+ return;
3021
+ }
3022
+ const chunks = [];
3023
+ req.on("data", (chunk) => chunks.push(chunk));
3024
+ req.on("end", () => {
3025
+ try {
3026
+ const newBlockSource = Buffer.concat(chunks).toString("utf8");
3027
+ const fileSource = fs.readFileSync(absPath, "utf8");
3028
+ const updated = replaceYattBlock(fileSource, blockIdx, newBlockSource);
3029
+ fs.writeFileSync(absPath, updated, "utf8");
3030
+ res.writeHead(200, { "Content-Type": "application/json" });
3031
+ res.end('"ok"');
3032
+ sse.broadcast("reload");
3033
+ } catch (e) {
3034
+ res.writeHead(500, { "Content-Type": "application/json" });
3035
+ res.end(JSON.stringify({ error: e.message }));
3036
+ }
3037
+ });
3038
+ req.on("error", (e) => {
3039
+ res.writeHead(500, { "Content-Type": "application/json" });
3040
+ res.end(JSON.stringify({ error: e.message }));
3041
+ });
3042
+ return;
3043
+ }
3044
+ if (pathname === "/events") {
3045
+ sse.add(res);
3046
+ return;
3047
+ }
3048
+ if (pathname === "/api/git/status" && req.method === "GET") {
3049
+ gitStatus(rootDir).then((status) => {
3050
+ res.writeHead(200, { "Content-Type": "application/json" });
3051
+ res.end(JSON.stringify(status));
3052
+ }).catch((e) => {
3053
+ res.writeHead(500, { "Content-Type": "application/json" });
3054
+ res.end(JSON.stringify({ error: e.message }));
3055
+ });
3056
+ return;
3057
+ }
3058
+ if (pathname === "/api/git/pull" && req.method === "POST") {
3059
+ runGit(["pull"], rootDir).then((r) => {
3060
+ const conflict = r.stdout.includes("CONFLICT") || r.stderr.includes("CONFLICT");
3061
+ res.writeHead(200, { "Content-Type": "application/json" });
3062
+ res.end(JSON.stringify({ ok: r.ok && !conflict, conflict, output: r.stdout || r.stderr }));
3063
+ if (r.ok && !conflict) sse.broadcast("reload");
3064
+ }).catch((e) => {
3065
+ res.writeHead(500, { "Content-Type": "application/json" });
3066
+ res.end(JSON.stringify({ error: e.message }));
3067
+ });
3068
+ return;
3069
+ }
3070
+ if (pathname === "/api/git/commit" && req.method === "POST") {
3071
+ const chunks = [];
3072
+ req.on("data", (c) => chunks.push(c));
3073
+ req.on("end", () => {
3074
+ let msg = "Update tasks";
3075
+ try {
3076
+ msg = JSON.parse(Buffer.concat(chunks).toString()).message || msg;
3077
+ } catch {
3078
+ }
3079
+ runGit(["add", "-A"], rootDir).then((a) => {
3080
+ if (!a.ok) throw new Error(a.stderr || "git add failed");
3081
+ return runGit(["commit", "-m", msg], rootDir);
3082
+ }).then((r) => {
3083
+ const noop = r.stdout.includes("nothing to commit");
3084
+ res.writeHead(200, { "Content-Type": "application/json" });
3085
+ res.end(JSON.stringify({ ok: r.ok || noop, noop, output: r.stdout || r.stderr }));
3086
+ }).catch((e) => {
3087
+ res.writeHead(500, { "Content-Type": "application/json" });
3088
+ res.end(JSON.stringify({ error: e.message }));
3089
+ });
3090
+ });
3091
+ return;
3092
+ }
3093
+ if (pathname === "/api/git/push" && req.method === "POST") {
3094
+ runGit(["push"], rootDir).then((r) => {
3095
+ const rejected = r.stderr.includes("rejected") || r.stderr.includes("[rejected]");
3096
+ res.writeHead(200, { "Content-Type": "application/json" });
3097
+ res.end(JSON.stringify({ ok: r.ok && !rejected, rejected, output: r.stdout || r.stderr }));
3098
+ }).catch((e) => {
3099
+ res.writeHead(500, { "Content-Type": "application/json" });
3100
+ res.end(JSON.stringify({ error: e.message }));
3101
+ });
3102
+ return;
3103
+ }
3104
+ res.writeHead(404, { "Content-Type": "text/plain" });
3105
+ res.end("Not found");
3106
+ });
3107
+ }
3108
+ function runGit(args, cwd) {
3109
+ return new Promise((resolve2) => {
3110
+ execFile("git", args, { cwd, timeout: 15e3 }, (err, stdout, stderr) => {
3111
+ resolve2({ ok: !err, stdout: stdout.trim(), stderr: stderr.trim() });
3112
+ });
3113
+ });
3114
+ }
3115
+ async function gitStatus(cwd) {
3116
+ const fallback = { available: false, dirty: false, ahead: 0, behind: 0, branch: "", hasRemote: false };
3117
+ const branchR = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
3118
+ if (!branchR.ok) return fallback;
3119
+ const branch = branchR.stdout || "HEAD";
3120
+ const porcelain = await runGit(["status", "--porcelain"], cwd);
3121
+ if (!porcelain.ok) return fallback;
3122
+ const dirty = porcelain.stdout.length > 0;
3123
+ const remoteR = await runGit(["rev-parse", "--abbrev-ref", "@{u}"], cwd);
3124
+ const hasRemote = remoteR.ok;
3125
+ let ahead = 0, behind = 0;
3126
+ if (hasRemote) {
3127
+ const countR = await runGit(["rev-list", "--left-right", "--count", "HEAD...@{u}"], cwd);
3128
+ if (countR.ok) {
3129
+ const parts = countR.stdout.split(/\s+/);
3130
+ ahead = parseInt(parts[0] ?? "0", 10) || 0;
3131
+ behind = parseInt(parts[1] ?? "0", 10) || 0;
3132
+ }
3133
+ }
3134
+ return { available: true, dirty, ahead, behind, branch, hasRemote };
3135
+ }
3136
+ function isPortFree(port) {
3137
+ return new Promise((resolve2) => {
3138
+ const srv = http.createServer();
3139
+ srv.once("error", () => resolve2(false));
3140
+ srv.once("listening", () => {
3141
+ srv.close();
3142
+ resolve2(true);
3143
+ });
3144
+ srv.listen(port, "127.0.0.1");
3145
+ });
3146
+ }
3147
+ async function findFreePort(start) {
3148
+ for (let p = start; p < start + 20; p++) {
3149
+ if (await isPortFree(p)) return p;
3150
+ }
3151
+ return start;
3152
+ }
3153
+ var { folder, port: preferredPort, noOpen } = parseArgs(process.argv.slice(2));
3154
+ if (!fs.existsSync(folder)) {
3155
+ process.stderr.write(`Error: folder not found: ${folder}
3156
+ `);
3157
+ process.exit(1);
3158
+ }
3159
+ (async () => {
3160
+ const port = await findFreePort(preferredPort);
3161
+ const addr = `http://localhost:${port}`;
3162
+ if (port !== preferredPort) {
3163
+ process.stdout.write(`Port ${preferredPort} in use, using ${port} instead.
3164
+ `);
3165
+ }
3166
+ const sse = new SseManager();
3167
+ watchFolder(folder, sse);
3168
+ const server = createServer2(folder, port, sse);
3169
+ server.listen(port, "127.0.0.1", () => {
3170
+ process.stdout.write(`
3171
+ YATT \xB7 ${folder}
3172
+ `);
3173
+ process.stdout.write(`
3174
+ \u2192 ${addr}
3175
+
3176
+ `);
3177
+ process.stdout.write(`Ctrl+C to stop.
3178
+
3179
+ `);
3180
+ if (!noOpen) openBrowser(addr);
3181
+ });
3182
+ server.on("error", (err) => {
3183
+ process.stderr.write(`Server error: ${err.message}
3184
+ `);
3185
+ process.exit(1);
3186
+ });
3187
+ })();