@tenonhq/dovetail-dashboard 0.0.18 → 0.0.20

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenonhq/dovetail-dashboard",
3
- "version": "0.0.18",
3
+ "version": "0.0.20",
4
4
  "description": "Update Set Dashboard for Dovetail",
5
5
  "main": "server.js",
6
6
  "scripts": {
@@ -18,10 +18,15 @@
18
18
  "tough-cookie": "^4.1.3"
19
19
  },
20
20
  "engines": {
21
- "node": ">=20"
21
+ "node": ">=22"
22
22
  },
23
23
  "license": "ISC",
24
24
  "publishConfig": {
25
25
  "access": "public"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/TenonHQ/Dovetail.git",
30
+ "directory": "packages/dashboard"
26
31
  }
27
32
  }
@@ -82,6 +82,70 @@
82
82
  font-size: 12px;
83
83
  color: var(--brand);
84
84
  }
85
+ .cp-rail-no-match {
86
+ padding: 20px 16px;
87
+ color: var(--fg-muted);
88
+ font-size: 13px;
89
+ line-height: 1.55;
90
+ }
91
+ .cp-rail-no-match-q {
92
+ font-family: var(--font-mono);
93
+ color: var(--fg);
94
+ }
95
+
96
+ /* ── Search box ─────────────────────────────────────────────────────────── */
97
+ .cp-search {
98
+ position: relative;
99
+ padding: 10px 12px;
100
+ border-bottom: 1px solid var(--border);
101
+ background: var(--bg-paper);
102
+ }
103
+ .cp-search-input {
104
+ width: 100%;
105
+ box-sizing: border-box;
106
+ padding: 7px 28px 7px 10px;
107
+ font-size: 13px;
108
+ font-family: inherit;
109
+ color: var(--fg);
110
+ background: var(--bg);
111
+ border: 1px solid var(--border);
112
+ border-radius: var(--radius-sm);
113
+ outline: none;
114
+ transition: border-color var(--dur-1) var(--ease-out),
115
+ box-shadow var(--dur-1) var(--ease-out);
116
+ }
117
+ .cp-search-input::placeholder { color: var(--fg-muted); }
118
+ .cp-search-input:focus {
119
+ border-color: var(--accent);
120
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent);
121
+ }
122
+ /* Hide the native clear "x" on WebKit since we render our own button. */
123
+ .cp-search-input::-webkit-search-cancel-button { -webkit-appearance: none; }
124
+
125
+ .cp-search-clear {
126
+ position: absolute;
127
+ right: 16px;
128
+ top: 50%;
129
+ transform: translateY(-50%);
130
+ width: 20px;
131
+ height: 20px;
132
+ display: flex;
133
+ align-items: center;
134
+ justify-content: center;
135
+ background: transparent;
136
+ border: 0;
137
+ color: var(--fg-muted);
138
+ font-size: 16px;
139
+ line-height: 1;
140
+ cursor: pointer;
141
+ border-radius: var(--radius-xs);
142
+ padding: 0;
143
+ }
144
+ .cp-search-clear:hover {
145
+ background: var(--bg-muted);
146
+ color: var(--fg);
147
+ }
148
+
85
149
  .cp-list { list-style: none; }
86
150
  .cp-list-item {
87
151
  padding: 12px 16px;
@@ -266,6 +330,25 @@
266
330
  border-radius: var(--radius-md);
267
331
  white-space: pre-wrap;
268
332
  }
333
+ .cp-mermaid-fallback {
334
+ background: var(--bg-paper);
335
+ border: 1px solid var(--border);
336
+ border-radius: var(--radius-md);
337
+ padding: 12px;
338
+ }
339
+ .cp-mermaid-fallback-note {
340
+ color: var(--error-700);
341
+ font-size: 12px;
342
+ margin-bottom: 8px;
343
+ }
344
+ .cp-mermaid-fallback pre {
345
+ margin: 0;
346
+ overflow: auto;
347
+ font-family: var(--font-mono);
348
+ font-size: 12px;
349
+ line-height: 1.5;
350
+ white-space: pre;
351
+ }
269
352
 
270
353
  /* ═══════════════════════════════════════════════════════════════════════════
271
354
  cp-c-* Structured Component Library
@@ -42,10 +42,32 @@
42
42
  <span class="cp-rail-title">Plans</span>
43
43
  <span class="cp-count" id="cp-count">0</span>
44
44
  </div>
45
+ <div class="cp-search">
46
+ <input
47
+ type="search"
48
+ class="cp-search-input"
49
+ id="cp-search-input"
50
+ placeholder="Search plans… (press / to focus)"
51
+ aria-label="Search plans by title, slug, status, or content"
52
+ autocomplete="off"
53
+ spellcheck="false"
54
+ />
55
+ <button
56
+ type="button"
57
+ class="cp-search-clear"
58
+ id="cp-search-clear"
59
+ aria-label="Clear search"
60
+ title="Clear (Esc)"
61
+ hidden
62
+ >×</button>
63
+ </div>
45
64
  <div class="cp-rail-empty" id="cp-rail-empty">
46
65
  Waiting for plans. Run <code>push_plan</code> from a Claude Code
47
66
  session to populate this list.
48
67
  </div>
68
+ <div class="cp-rail-no-match" id="cp-rail-no-match" hidden>
69
+ No plans match <span class="cp-rail-no-match-q" id="cp-rail-no-match-q"></span>.
70
+ </div>
49
71
  <ul class="cp-list" id="cp-list"></ul>
50
72
  </aside>
51
73
 
@@ -73,7 +95,7 @@
73
95
 
74
96
  <script src="https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js"></script>
75
97
  <script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.5/dist/purify.min.js"></script>
76
- <script src="https://cdn.jsdelivr.net/npm/mermaid@10.9.1/dist/mermaid.min.js"></script>
98
+ <script src="https://cdn.jsdelivr.net/npm/mermaid@10.9.6/dist/mermaid.min.js"></script>
77
99
  <script src="claude-plans.js"></script>
78
100
  </body>
79
101
  </html>
@@ -1,7 +1,8 @@
1
1
  /* /claude-plans page logic.
2
2
  * - Fetches initial state from REST.
3
3
  * - Subscribes to /api/claude-plans/stream (SSE) for live updates.
4
- * - Renders markdown via marked + DOMPurify, Mermaid via mermaid.run().
4
+ * - Renders markdown via marked + DOMPurify, Mermaid via mermaid.parse()/render()
5
+ * with a graceful raw-source fallback when a diagram fails to parse.
5
6
  */
6
7
 
7
8
  (function () {
@@ -16,7 +17,10 @@
16
17
  window.mermaid.initialize({
17
18
  startOnLoad: false,
18
19
  theme: currentTheme() === "dark" ? "dark" : "default",
19
- securityLevel: "strict"
20
+ securityLevel: "strict",
21
+ // Never inject mermaid's "Syntax error in text" bomb SVG into the page.
22
+ // Bad sources are handled by renderMermaid's graceful fallback instead.
23
+ suppressErrorRendering: true
20
24
  });
21
25
  }
22
26
  }
@@ -30,7 +34,8 @@
30
34
  plans: new Map(), // slug -> plan
31
35
  artifacts: new Map(), // slug -> Map<artifactSlug, artifact>
32
36
  selectedSlug: null,
33
- activeTab: "plan"
37
+ activeTab: "plan",
38
+ query: "" // lowercased search query; "" means show all
34
39
  };
35
40
 
36
41
  var els = {
@@ -38,6 +43,10 @@
38
43
  list: document.getElementById("cp-list"),
39
44
  count: document.getElementById("cp-count"),
40
45
  railEmpty: document.getElementById("cp-rail-empty"),
46
+ railNoMatch: document.getElementById("cp-rail-no-match"),
47
+ railNoMatchQ: document.getElementById("cp-rail-no-match-q"),
48
+ searchInput: document.getElementById("cp-search-input"),
49
+ searchClear: document.getElementById("cp-search-clear"),
41
50
  detailEmpty: document.getElementById("cp-detail-empty"),
42
51
  detailBody: document.getElementById("cp-detail-body"),
43
52
  detailTitle: document.getElementById("cp-detail-title"),
@@ -49,12 +58,46 @@
49
58
  tabs: document.querySelectorAll(".cp-tab")
50
59
  };
51
60
 
52
- function sortedPlans() {
61
+ // True when plan matches q across title, slug, status, plan markdown/html,
62
+ // pr title, and every artifact title + content. Lets you find a plan by any
63
+ // snippet that appears anywhere in or attached to it.
64
+ function planMatchesQuery(plan, q) {
65
+ if (!q) return true;
66
+ var haystacks = [
67
+ plan.title,
68
+ plan.slug,
69
+ plan.status,
70
+ plan.pr_title,
71
+ plan.content_md,
72
+ plan.content_html
73
+ ];
74
+ var bucket = state.artifacts.get(plan.slug);
75
+ if (bucket) {
76
+ bucket.forEach(function (a) {
77
+ haystacks.push(a.title);
78
+ haystacks.push(a.content);
79
+ });
80
+ }
81
+ for (var i = 0; i < haystacks.length; i++) {
82
+ var h = haystacks[i];
83
+ if (h && String(h).toLowerCase().indexOf(q) !== -1) return true;
84
+ }
85
+ return false;
86
+ }
87
+
88
+ function allPlansSorted() {
53
89
  return Array.from(state.plans.values()).sort(function (a, b) {
54
90
  return (b.updated_at || "").localeCompare(a.updated_at || "");
55
91
  });
56
92
  }
57
93
 
94
+ function sortedPlans() {
95
+ var q = state.query;
96
+ var all = allPlansSorted();
97
+ if (!q) return all;
98
+ return all.filter(function (p) { return planMatchesQuery(p, q); });
99
+ }
100
+
58
101
  function sortedArtifacts(slug) {
59
102
  var map = state.artifacts.get(slug);
60
103
  if (!map) return [];
@@ -99,27 +142,62 @@
99
142
  target.innerHTML = window.DOMPurify.sanitize(html);
100
143
  }
101
144
 
145
+ // Strip a wrapping markdown code fence and normalize whitespace/line endings.
146
+ // LLM-authored sources often arrive fenced (```mermaid … ```) or with CRLF/BOM,
147
+ // which the parser rejects on line 1.
148
+ function preprocessMermaid(src) {
149
+ var s = src == null ? "" : String(src);
150
+ s = s.replace(/\r\n/g, "\n").replace(/^\uFEFF/, "");
151
+ s = s.replace(/^\s*```[^\n]*\n/, "").replace(/\n```\s*$/, "");
152
+ return s.trim();
153
+ }
154
+
155
+ // Graceful fallback: show the raw source instead of mermaid's "Syntax error"
156
+ // bomb so one bad diagram never poisons the page.
157
+ function renderMermaidFallback(source, target) {
158
+ target.classList.remove("cp-mermaid");
159
+ target.classList.add("cp-mermaid-fallback");
160
+ target.textContent = "";
161
+ var note = document.createElement("div");
162
+ note.className = "cp-mermaid-fallback-note";
163
+ note.textContent = "⚠ Diagram failed to render — showing source";
164
+ var pre = document.createElement("pre");
165
+ var code = document.createElement("code");
166
+ code.textContent = source;
167
+ pre.appendChild(code);
168
+ target.appendChild(note);
169
+ target.appendChild(pre);
170
+ }
171
+
102
172
  function renderMermaid(source, target) {
173
+ target.classList.remove("cp-mermaid-fallback", "cp-mermaid-error");
103
174
  target.classList.add("cp-mermaid");
104
175
  target.textContent = "";
105
176
  if (!window.mermaid || typeof window.mermaid.render !== "function") {
106
- target.textContent = source;
177
+ renderMermaidFallback(source, target);
107
178
  return;
108
179
  }
180
+ var clean = preprocessMermaid(source);
109
181
  var id = "mmd-" + Math.random().toString(36).slice(2, 10);
110
- try {
111
- window.mermaid.render(id, source).then(
182
+ var doRender = function () {
183
+ window.mermaid.render(id, clean).then(
112
184
  function (out) { target.innerHTML = out.svg; },
113
- function (err) {
114
- target.classList.remove("cp-mermaid");
115
- target.classList.add("cp-mermaid-error");
116
- target.textContent = "mermaid error: " + (err && err.message ? err.message : String(err));
117
- }
185
+ function () { renderMermaidFallback(source, target); }
118
186
  );
187
+ };
188
+ try {
189
+ // Validate before rendering. suppressErrors makes parse resolve false
190
+ // (instead of throwing) on bad source, so we can fall back cleanly.
191
+ if (typeof window.mermaid.parse === "function") {
192
+ window.mermaid.parse(clean, { suppressErrors: true }).then(
193
+ function (ok) { if (ok) { doRender(); } else { renderMermaidFallback(source, target); } },
194
+ function () { renderMermaidFallback(source, target); }
195
+ );
196
+ } else {
197
+ doRender();
198
+ }
119
199
  } catch (err) {
120
- target.classList.remove("cp-mermaid");
121
- target.classList.add("cp-mermaid-error");
122
- target.textContent = "mermaid error: " + err.message;
200
+ renderMermaidFallback(source, target);
123
201
  }
124
202
  }
125
203
 
@@ -203,13 +281,25 @@
203
281
 
204
282
  function renderRail() {
205
283
  var plans = sortedPlans();
206
- els.count.textContent = String(plans.length);
284
+ var totalPlans = state.plans.size;
285
+ var isFiltering = !!state.query;
286
+ els.count.textContent = isFiltering
287
+ ? plans.length + " / " + totalPlans
288
+ : String(totalPlans);
207
289
  if (plans.length === 0) {
208
- els.railEmpty.style.display = "block";
209
290
  els.list.innerHTML = "";
291
+ if (isFiltering && totalPlans > 0) {
292
+ els.railEmpty.style.display = "none";
293
+ els.railNoMatch.hidden = false;
294
+ if (els.railNoMatchQ) els.railNoMatchQ.textContent = '"' + state.query + '"';
295
+ } else {
296
+ els.railEmpty.style.display = "block";
297
+ els.railNoMatch.hidden = true;
298
+ }
210
299
  return;
211
300
  }
212
301
  els.railEmpty.style.display = "none";
302
+ els.railNoMatch.hidden = true;
213
303
  els.list.innerHTML = "";
214
304
  plans.forEach(function (plan) {
215
305
  var artifactCount = (state.artifacts.get(plan.slug) || new Map()).size;
@@ -389,6 +479,69 @@
389
479
  btn.addEventListener("click", function () { setActiveTab(btn.dataset.tab); });
390
480
  });
391
481
 
482
+ /* ─── Search ───────────────────────────────────────────────────────────────
483
+ * Filter the rail by title/slug/status/content (plan + artifacts).
484
+ * 120ms debounce keeps typing snappy even with hundreds of plans.
485
+ *
486
+ * Selection rule: when filtering narrows the rail, only auto-select a new
487
+ * plan if the currently selected one is filtered out — picking the first
488
+ * visible match. Don't yank the user off a plan they're already reading.
489
+ */
490
+ var searchDebounce = null;
491
+ function applyQuery(raw) {
492
+ var q = (raw == null ? "" : String(raw)).trim().toLowerCase();
493
+ state.query = q;
494
+ if (els.searchClear) els.searchClear.hidden = q.length === 0;
495
+ renderRail();
496
+ if (q) {
497
+ var visible = sortedPlans();
498
+ var stillVisible = state.selectedSlug &&
499
+ visible.some(function (p) { return p.slug === state.selectedSlug; });
500
+ if (!stillVisible && visible.length > 0) {
501
+ selectPlan(visible[0].slug);
502
+ }
503
+ }
504
+ }
505
+
506
+ function setupSearch() {
507
+ if (els.searchInput) {
508
+ els.searchInput.addEventListener("input", function (e) {
509
+ var v = e.target.value;
510
+ if (searchDebounce) clearTimeout(searchDebounce);
511
+ searchDebounce = setTimeout(function () { applyQuery(v); }, 120);
512
+ });
513
+ els.searchInput.addEventListener("keydown", function (e) {
514
+ if (e.key === "Escape") {
515
+ e.preventDefault();
516
+ els.searchInput.value = "";
517
+ applyQuery("");
518
+ els.searchInput.blur();
519
+ }
520
+ });
521
+ }
522
+ if (els.searchClear) {
523
+ els.searchClear.addEventListener("click", function () {
524
+ if (els.searchInput) els.searchInput.value = "";
525
+ applyQuery("");
526
+ if (els.searchInput) els.searchInput.focus();
527
+ });
528
+ }
529
+ // Press "/" anywhere (except in another input) to focus the search box.
530
+ document.addEventListener("keydown", function (e) {
531
+ if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey) return;
532
+ var t = e.target;
533
+ var tag = t && t.tagName;
534
+ var isEditable = tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" ||
535
+ (t && t.isContentEditable);
536
+ if (isEditable) return;
537
+ e.preventDefault();
538
+ if (els.searchInput) {
539
+ els.searchInput.focus();
540
+ els.searchInput.select();
541
+ }
542
+ });
543
+ }
544
+
392
545
  function upsertPlan(plan) {
393
546
  state.plans.set(plan.slug, plan);
394
547
  renderRail();
@@ -510,6 +663,7 @@
510
663
 
511
664
  document.addEventListener("DOMContentLoaded", function () {
512
665
  setupTheme();
666
+ setupSearch();
513
667
  setActiveTab("plan");
514
668
  loadInitial().then(startStream);
515
669
  });