claude-code-kanban 4.14.1 → 4.16.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.
package/public/style.css CHANGED
@@ -31,6 +31,10 @@
31
31
  --plan-dim: rgba(134, 168, 134, 0.18);
32
32
  --mono: 'IBM Plex Mono', monospace;
33
33
  --serif: 'Playfair Display', serif;
34
+ --card-gap: 7px;
35
+ /* the name's supporting lines read as one unit with it, so they sit tighter
36
+ than the gap separating the card's independent rows */
37
+ --card-gap-tight: 3px;
34
38
  /* aliases: many rules reference --font-mono/--font-serif; keep them on-brand */
35
39
  --font-mono: var(--mono);
36
40
  --font-serif: var(--serif);
@@ -89,7 +93,7 @@ body::before {
89
93
 
90
94
  /* #region SIDEBAR */
91
95
  .sidebar {
92
- width: var(--sidebar-width, 300px);
96
+ width: var(--sidebar-width, 310px);
93
97
  background: var(--bg-surface);
94
98
  border-right: 1px solid color-mix(in srgb, var(--border) 35%, transparent);
95
99
  box-shadow: 1px 0 12px rgba(0, 0, 0, 0.04);
@@ -451,11 +455,11 @@ body::before {
451
455
  .session-item {
452
456
  display: block;
453
457
  width: 100%;
454
- padding: 12px 14px;
455
- margin-bottom: 4px;
458
+ padding: 13px 15px;
459
+ margin-bottom: 6px;
456
460
  background: transparent;
457
461
  border: 1px solid var(--border);
458
- border-radius: 8px;
462
+ border-radius: 9px;
459
463
  text-align: left;
460
464
  cursor: pointer;
461
465
  transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
@@ -467,7 +471,7 @@ body::before {
467
471
  }
468
472
 
469
473
  .session-name {
470
- font-size: 14px;
474
+ font-size: 13px;
471
475
  color: var(--text-primary);
472
476
  white-space: nowrap;
473
477
  overflow: hidden;
@@ -501,7 +505,7 @@ body::before {
501
505
  font-size: 12px;
502
506
  font-weight: 450;
503
507
  color: var(--text-tertiary);
504
- margin-top: 2px;
508
+ margin-top: var(--card-gap-tight);
505
509
  white-space: nowrap;
506
510
  overflow: hidden;
507
511
  }
@@ -509,7 +513,7 @@ body::before {
509
513
  .session-branch {
510
514
  font-size: 10px;
511
515
  color: var(--text-secondary);
512
- margin-top: 2px;
516
+ margin-top: var(--card-gap-tight);
513
517
  display: block;
514
518
  font-family: var(--mono);
515
519
  white-space: nowrap;
@@ -520,7 +524,7 @@ body::before {
520
524
  .session-plan {
521
525
  font-size: 10px;
522
526
  color: var(--plan);
523
- margin-top: 2px;
527
+ margin-top: var(--card-gap-tight);
524
528
  white-space: nowrap;
525
529
  overflow: hidden;
526
530
  text-overflow: ellipsis;
@@ -528,7 +532,7 @@ body::before {
528
532
 
529
533
  .session-goal {
530
534
  font-size: 10px;
531
- margin-top: 2px;
535
+ margin-top: var(--card-gap-tight);
532
536
  white-space: nowrap;
533
537
  overflow: hidden;
534
538
  text-overflow: ellipsis;
@@ -552,8 +556,9 @@ body::before {
552
556
  .session-progress {
553
557
  display: flex;
554
558
  align-items: center;
555
- gap: 8px;
556
- margin-top: 8px;
559
+ gap: 9px;
560
+ margin-top: var(--card-gap);
561
+ line-height: 1;
557
562
  }
558
563
 
559
564
  .progress-bar {
@@ -579,7 +584,7 @@ body::before {
579
584
 
580
585
  .context-bar {
581
586
  display: none;
582
- margin-top: 6px;
587
+ margin-top: var(--card-gap);
583
588
  }
584
589
  .has-context .context-bar {
585
590
  display: block;
@@ -682,7 +687,7 @@ body::before {
682
687
  font-size: 11px;
683
688
  font-weight: 450;
684
689
  color: var(--text-tertiary);
685
- margin-top: 6px;
690
+ margin-top: var(--card-gap);
686
691
  }
687
692
 
688
693
  .session-latest-msg {
@@ -947,8 +952,11 @@ body::before {
947
952
  background: var(--bg-hover);
948
953
  color: var(--text-primary);
949
954
  }
955
+ /* Highlighted with the row's *own* theme accent rather than the active theme's, so the selected
956
+ row previews the theme it names. --sw-accent comes from the theme-swatch-<id> class on the row. */
950
957
  .theme-menu-item.on {
951
- color: var(--accent-text);
958
+ color: var(--sw-accent, var(--accent-text));
959
+ background: color-mix(in srgb, var(--sw-accent, var(--accent-text)) 16%, transparent);
952
960
  }
953
961
  /* 3-dot palette preview: surface, accent, ink (colors from themes.css vars) */
954
962
  .theme-swatch {
@@ -1573,7 +1581,7 @@ body::before {
1573
1581
  .session-indicators {
1574
1582
  display: flex;
1575
1583
  align-items: center;
1576
- gap: 2px;
1584
+ gap: 5px;
1577
1585
  flex-shrink: 0;
1578
1586
  }
1579
1587
 
@@ -1598,13 +1606,18 @@ body::before {
1598
1606
  flex-shrink: 0;
1599
1607
  }
1600
1608
 
1601
- .team-info-btn {
1602
- width: 24px;
1603
- height: 24px;
1609
+ /* Every clickable session-card icon shares one box so they blend into a single row;
1610
+ resizing the row means editing this rule, not four. Per-icon rules below override
1611
+ only what actually differs (color, glyph size). */
1612
+ .team-info-btn,
1613
+ .plan-indicator,
1614
+ .loop-badge,
1615
+ .workflow-badge {
1616
+ width: 18px;
1617
+ height: 18px;
1604
1618
  display: inline-flex;
1605
1619
  align-items: center;
1606
1620
  justify-content: center;
1607
- font-size: 11px;
1608
1621
  background: transparent;
1609
1622
  border: 1px solid transparent;
1610
1623
  border-radius: 4px;
@@ -1614,76 +1627,39 @@ body::before {
1614
1627
  transition: all 0.15s ease;
1615
1628
  }
1616
1629
 
1617
- .team-info-btn:hover {
1630
+ .team-info-btn:hover,
1631
+ .loop-badge:hover,
1632
+ .workflow-badge:hover {
1618
1633
  background: var(--bg-hover);
1619
1634
  border-color: var(--border);
1620
1635
  color: var(--text-secondary);
1621
1636
  }
1622
1637
 
1638
+ /* The boxes are wider than their 12px glyphs, so the leading icon would sit inset
1639
+ from the card text above it. Cancel that inset — but only when a boxed icon leads;
1640
+ the pill badges and .team-badge have a different inset and must not be shifted. */
1641
+ .session-indicators > .team-info-btn:first-child,
1642
+ .session-indicators > .plan-indicator:first-child,
1643
+ .session-indicators > .loop-badge:first-child,
1644
+ .session-indicators > .workflow-badge:first-child {
1645
+ margin-left: -3px;
1646
+ }
1647
+
1648
+ .team-info-btn {
1649
+ font-size: 11px;
1650
+ }
1651
+
1623
1652
  .plan-indicator {
1624
- width: 24px;
1625
- height: 24px;
1626
- display: inline-flex;
1627
- align-items: center;
1628
- justify-content: center;
1629
1653
  background: var(--plan-dim);
1630
- border: 1px solid transparent;
1631
- border-radius: 4px;
1632
1654
  color: var(--plan);
1633
- cursor: pointer;
1634
- flex-shrink: 0;
1635
- transition: all 0.15s ease;
1636
1655
  }
1637
1656
 
1638
1657
  .plan-indicator:hover {
1639
- background: var(--plan-dim);
1640
1658
  border-color: var(--plan);
1641
1659
  }
1642
1660
 
1643
- /* Match .team-info-btn so the wakeups clock blends with the other session-card icons. */
1644
- .loop-badge {
1645
- width: 24px;
1646
- height: 24px;
1647
- display: inline-flex;
1648
- align-items: center;
1649
- justify-content: center;
1650
- background: transparent;
1651
- border: 1px solid transparent;
1652
- border-radius: 4px;
1653
- color: var(--text-muted);
1654
- cursor: pointer;
1655
- flex-shrink: 0;
1656
- transition: all 0.15s ease;
1657
- }
1658
-
1659
- .loop-badge:hover {
1660
- background: var(--bg-hover);
1661
- border-color: var(--border);
1662
- color: var(--text-secondary);
1663
- }
1664
-
1665
1661
  /* #region WORKFLOW */
1666
- /* Match .team-info-btn so the gear blends with the other session-card icons. */
1667
- .workflow-badge {
1668
- width: 24px;
1669
- height: 24px;
1670
- display: inline-flex;
1671
- align-items: center;
1672
- justify-content: center;
1673
- background: transparent;
1674
- border: 1px solid transparent;
1675
- border-radius: 4px;
1676
- color: var(--text-muted);
1677
- cursor: pointer;
1678
- flex-shrink: 0;
1679
- transition: all 0.15s ease;
1680
- }
1681
-
1682
- .workflow-badge:hover {
1683
- background: var(--bg-hover);
1684
- border-color: var(--border);
1685
- color: var(--text-secondary);
1686
- }
1662
+ /* .workflow-badge's box is defined with the other session-card icons in TEAM_BADGE. */
1687
1663
 
1688
1664
  .workflow-row {
1689
1665
  border: 1px solid var(--border);
@@ -3372,6 +3348,113 @@ body::before {
3372
3348
  min-width: 0;
3373
3349
  }
3374
3350
 
3351
+ /* margin-left:auto pins every × to the same right edge, so they read as a column
3352
+ however long the names in between are. */
3353
+ .linked-doc-remove {
3354
+ margin-left: auto;
3355
+ align-self: center;
3356
+ flex-shrink: 0;
3357
+ width: 16px;
3358
+ height: 16px;
3359
+ display: inline-flex;
3360
+ align-items: center;
3361
+ justify-content: center;
3362
+ padding: 0;
3363
+ background: none;
3364
+ border: none;
3365
+ border-radius: 3px;
3366
+ color: var(--text-muted);
3367
+ font-family: var(--mono);
3368
+ font-size: 14px;
3369
+ line-height: 1;
3370
+ cursor: pointer;
3371
+ opacity: 0.45;
3372
+ transition: all 0.15s ease;
3373
+ }
3374
+
3375
+ .linked-doc-item:hover .linked-doc-remove {
3376
+ opacity: 1;
3377
+ }
3378
+
3379
+ .linked-doc-remove:hover {
3380
+ background: var(--danger-dim);
3381
+ color: var(--danger);
3382
+ }
3383
+
3384
+ /* A linked file the previewer can't render opens in the editor — the dashed
3385
+ underline keeps that different destination visible before the click. */
3386
+ .linked-doc-item.is-editor .linked-doc-link {
3387
+ color: var(--text-secondary);
3388
+ text-decoration-style: dashed;
3389
+ }
3390
+
3391
+ .linked-docs-empty {
3392
+ color: var(--text-muted);
3393
+ font-size: 11px;
3394
+ font-style: italic;
3395
+ }
3396
+
3397
+ .linked-docs-add-btn {
3398
+ display: inline-flex;
3399
+ align-items: center;
3400
+ justify-content: center;
3401
+ width: 18px;
3402
+ height: 18px;
3403
+ padding: 0;
3404
+ line-height: 1;
3405
+ flex-shrink: 0;
3406
+ background: var(--bg-elevated);
3407
+ border: 1px solid var(--border);
3408
+ border-radius: 4px;
3409
+ color: var(--text-muted);
3410
+ font-family: var(--mono);
3411
+ font-size: 13px;
3412
+ cursor: pointer;
3413
+ transition: all 0.15s ease;
3414
+ }
3415
+
3416
+ .linked-docs-add-btn:hover {
3417
+ border-color: var(--accent);
3418
+ color: var(--accent-text);
3419
+ }
3420
+
3421
+ .linked-doc-editor-slot:not(:empty) {
3422
+ margin-bottom: 8px;
3423
+ }
3424
+
3425
+ .linked-doc-input {
3426
+ width: 100%;
3427
+ padding: 6px 8px;
3428
+ background: var(--bg-elevated);
3429
+ border: 1px solid var(--accent);
3430
+ border-radius: 4px;
3431
+ color: var(--text-primary);
3432
+ font-family: var(--mono);
3433
+ font-size: 12px;
3434
+ box-shadow: 0 0 0 2px var(--accent-dim);
3435
+ outline: none;
3436
+ }
3437
+
3438
+ .linked-doc-hint {
3439
+ color: var(--text-muted);
3440
+ font-size: 10px;
3441
+ margin-top: 4px;
3442
+ overflow: hidden;
3443
+ text-overflow: ellipsis;
3444
+ white-space: nowrap;
3445
+ }
3446
+
3447
+ /* Present but empty until a path fails to resolve. */
3448
+ .linked-doc-error:empty {
3449
+ display: none;
3450
+ }
3451
+
3452
+ .linked-doc-error {
3453
+ color: var(--danger);
3454
+ font-size: 11px;
3455
+ margin-top: 4px;
3456
+ }
3457
+
3375
3458
  /* #endregion */
3376
3459
 
3377
3460
  /* #region PERMISSION_PENDING */
@@ -3592,6 +3675,62 @@ body.light .msg-assistant .msg-text {
3592
3675
  max-height: 90vh;
3593
3676
  }
3594
3677
 
3678
+ .preview-modal-dialog .modal-body {
3679
+ flex: 0 1 auto;
3680
+ display: flex;
3681
+ flex-direction: column;
3682
+ overflow: hidden;
3683
+ }
3684
+
3685
+ #preview-modal-body {
3686
+ word-break: break-word;
3687
+ font-size: 13px;
3688
+ line-height: 1.6;
3689
+ overflow-y: auto;
3690
+ flex: 0 1 auto;
3691
+ min-height: 0;
3692
+ padding-right: 8px;
3693
+ }
3694
+
3695
+ #preview-modal-meta {
3696
+ margin-top: 12px;
3697
+ font-size: 11px;
3698
+ color: var(--text-muted);
3699
+ flex-shrink: 0;
3700
+ }
3701
+
3702
+ /* Rendered HTML pages need far more room than a markdown doc. */
3703
+ .modal.preview-modal-dialog.preview-html {
3704
+ width: 96vw;
3705
+ max-width: 96vw;
3706
+ height: 92vh;
3707
+ max-height: 92vh;
3708
+ }
3709
+
3710
+ /* Keeps the fullscreen button meaningful — the generic .fullscreen rule is smaller than this. */
3711
+ .modal.preview-modal-dialog.preview-html.fullscreen {
3712
+ width: 98vw;
3713
+ max-width: 98vw;
3714
+ height: 98vh;
3715
+ max-height: 98vh;
3716
+ }
3717
+
3718
+ .preview-html .modal-body,
3719
+ .preview-html #preview-modal-body {
3720
+ flex: 1 1 auto;
3721
+ overflow: hidden;
3722
+ padding-right: 0;
3723
+ }
3724
+
3725
+ .preview-html-frame {
3726
+ width: 100%;
3727
+ height: 100%;
3728
+ border: 0;
3729
+ border-radius: 6px;
3730
+ /* Pages authored for a light page default would be unreadable on the dark theme. */
3731
+ background: #fff;
3732
+ }
3733
+
3595
3734
  .preview-fm {
3596
3735
  margin: 0 0 14px;
3597
3736
  border: 1px solid var(--border);
package/server.js CHANGED
@@ -32,7 +32,8 @@ const {
32
32
 
33
33
  if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
34
34
  const { runInstall, runUninstall } = require("./install");
35
- (process.argv.includes("--install") ? runInstall() : runUninstall())
35
+ const pluginOnly = process.argv.includes("--plugin-only");
36
+ (process.argv.includes("--install") ? runInstall({ pluginOnly }) : runUninstall())
36
37
  .then(() => process.exit(0))
37
38
  .catch(e => { console.error(e.message); process.exit(1); });
38
39
  return;
@@ -2612,23 +2613,52 @@ app.delete('/api/tasks/:sessionId/:taskId', async (req, res) => {
2612
2613
  }
2613
2614
  });
2614
2615
 
2615
- // API: Markdown preview — read file and broadcast to clients
2616
- async function readMarkdownFile(absPath) {
2617
- const ext = path.extname(absPath).toLowerCase();
2618
- if (ext !== '.md' && ext !== '.markdown') {
2619
- const err = new Error('Only .md/.markdown files are allowed');
2620
- err.status = 400;
2621
- throw err;
2622
- }
2616
+ // API: File preview — read file and broadcast to clients
2617
+ const PREVIEW_KINDS = { '.md': 'markdown', '.markdown': 'markdown', '.html': 'html', '.htm': 'html' };
2618
+ // Whole files are pushed into a modal, so anything huge freezes the tab regardless of kind.
2619
+ const PREVIEW_MAX_BYTES = 8 * 1024 * 1024;
2620
+
2621
+ function previewError(status, message) {
2622
+ const err = new Error(message);
2623
+ err.status = status;
2624
+ return err;
2625
+ }
2626
+
2627
+ // Existence + kind of a file target, with the HTTP status codes both the preview and
2628
+ // the link-a-file endpoints report. `kind` is null for anything the previewer can't
2629
+ // render — the caller decides whether that disqualifies the path.
2630
+ async function statFileTarget(absPath) {
2623
2631
  try {
2624
- return await fs.readFile(absPath, 'utf8');
2632
+ const stats = await fs.stat(absPath);
2633
+ if (!stats.isFile()) throw previewError(400, 'Not a file');
2634
+ return { size: stats.size, kind: PREVIEW_KINDS[path.extname(absPath).toLowerCase()] || null };
2625
2635
  } catch (e) {
2626
- if (e.code === 'ENOENT') { const err = new Error('File not found'); err.status = 404; throw err; }
2627
- if (e.code === 'EISDIR') { const err = new Error('Not a file'); err.status = 400; throw err; }
2636
+ if (e.status) throw e;
2637
+ if (e.code === 'ENOENT') throw previewError(404, 'File not found');
2638
+ if (e.code === 'EISDIR') throw previewError(400, 'Not a file');
2628
2639
  throw e;
2629
2640
  }
2630
2641
  }
2631
2642
 
2643
+ // Checks the file is previewable without reading it — the broadcast path needs the
2644
+ // validation (and its status codes) but never the content.
2645
+ async function validatePreviewFile(absPath) {
2646
+ const { kind, size } = await statFileTarget(absPath);
2647
+ if (!kind) throw previewError(400, 'Only .md/.markdown/.html/.htm files are allowed');
2648
+ if (size > PREVIEW_MAX_BYTES) {
2649
+ throw previewError(
2650
+ 400,
2651
+ `Preview too large (${Math.round(size / 1048576)}MB, max ${PREVIEW_MAX_BYTES / 1048576}MB)`
2652
+ );
2653
+ }
2654
+ return { kind, size };
2655
+ }
2656
+
2657
+ async function readPreviewFile(absPath) {
2658
+ const { kind } = await validatePreviewFile(absPath);
2659
+ return { content: await fs.readFile(absPath, 'utf8'), kind };
2660
+ }
2661
+
2632
2662
  function resolvePreviewPath(filePath, base) {
2633
2663
  if (!filePath || typeof filePath !== 'string') return null;
2634
2664
  if (path.isAbsolute(filePath)) return filePath;
@@ -2650,8 +2680,10 @@ app.post('/api/preview', async (req, res) => {
2650
2680
  const { path: filePath, sessionId, base } = req.body || {};
2651
2681
  const abs = resolvePreviewPath(filePath, base);
2652
2682
  if (!abs) return res.status(400).json({ error: 'path is required' });
2653
- const content = await readMarkdownFile(abs);
2654
- broadcast({ type: 'preview:open', path: abs, content, sessionId: sessionId || null });
2683
+ // Validate here so the CLI still gets 400/404, but broadcast the path only —
2684
+ // each tab fetches the document itself instead of it being fanned out over SSE.
2685
+ await validatePreviewFile(abs);
2686
+ broadcast({ type: 'preview:open', path: abs, sessionId: sessionId || null });
2655
2687
  res.json({ success: true });
2656
2688
  } catch (error) {
2657
2689
  console.error('Error in /api/preview:', error);
@@ -2659,6 +2691,24 @@ app.post('/api/preview', async (req, res) => {
2659
2691
  }
2660
2692
  });
2661
2693
 
2694
+ // API: Link a file to a session's sidebar docs without opening the preview modal.
2695
+ // Not extension-restricted, matching /api/file/resolve — an unpreviewable link just
2696
+ // opens in the editor. Unlinking skips the stat so a deleted file can still be removed.
2697
+ app.post('/api/document/link', async (req, res) => {
2698
+ try {
2699
+ const { path: filePath, sessionId, unlink } = req.body || {};
2700
+ if (typeof sessionId !== 'string' || !sessionId) return res.status(400).json({ error: 'sessionId is required' });
2701
+ const abs = resolvePreviewPath(filePath);
2702
+ if (!abs) return res.status(400).json({ error: 'path is required' });
2703
+ if (!unlink) await statFileTarget(abs);
2704
+ broadcast({ type: 'document:link', path: abs, sessionId, unlink: !!unlink });
2705
+ res.json({ success: true });
2706
+ } catch (error) {
2707
+ console.error('Error in /api/document/link:', error);
2708
+ res.status(error.status || 500).json({ error: error.message || 'Link failed' });
2709
+ }
2710
+ });
2711
+
2662
2712
  app.get('/api/session/resolve', (req, res) => {
2663
2713
  try {
2664
2714
  const idArg = (req.query.id || '').toString();
@@ -2731,14 +2781,29 @@ app.get('/api/preview', async (req, res) => {
2731
2781
  try {
2732
2782
  const abs = resolvePreviewPath(req.query.path, req.query.base);
2733
2783
  if (!abs) return res.status(400).json({ error: 'path is required' });
2734
- const content = await readMarkdownFile(abs);
2735
- res.json({ path: abs, content });
2784
+ const { content, kind } = await readPreviewFile(abs);
2785
+ res.json({ path: abs, content, kind });
2736
2786
  } catch (error) {
2737
2787
  console.error('Error in GET /api/preview:', error);
2738
2788
  res.status(error.status || 500).json({ error: error.message || 'Preview failed' });
2739
2789
  }
2740
2790
  });
2741
2791
 
2792
+ // API: Resolve a hand-typed path to an absolute one that exists. Deliberately not
2793
+ // extension-restricted — a linked file the previewer can't render (kind: null) is
2794
+ // still linkable, the client just opens it in the editor instead.
2795
+ app.get('/api/file/resolve', async (req, res) => {
2796
+ try {
2797
+ const abs = resolvePreviewPath(req.query.path, req.query.base);
2798
+ if (!abs) return res.status(400).json({ error: 'path is required' });
2799
+ // No size cap here — an unpreviewable file of any size is still linkable.
2800
+ res.json({ path: abs, ...(await statFileTarget(abs)) });
2801
+ } catch (error) {
2802
+ console.error('Error in GET /api/file/resolve:', error);
2803
+ res.status(error.status || 500).json({ error: error.message || 'Failed to resolve file' });
2804
+ }
2805
+ });
2806
+
2742
2807
  // SSE endpoint for live updates
2743
2808
  app.get('/api/events', (req, res) => {
2744
2809
  res.setHeader('Content-Type', 'text/event-stream');