@tenonhq/dovetail-dashboard 0.0.17 → 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.17",
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
  }
package/public/app.js CHANGED
@@ -1046,3 +1046,31 @@ document.getElementById("task-search").addEventListener("input", function (e) {
1046
1046
 
1047
1047
  // Render task toggles (My Tasks)
1048
1048
  renderTaskToggles();
1049
+
1050
+ // --- Theme toggle ---
1051
+
1052
+ function currentTheme() {
1053
+ return document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
1054
+ }
1055
+
1056
+ function applyTheme(theme) {
1057
+ document.documentElement.setAttribute("data-theme", theme);
1058
+ try {
1059
+ localStorage.setItem("cp-theme", theme);
1060
+ } catch (e) {}
1061
+ var btn = document.getElementById("cp-theme-toggle");
1062
+ if (btn) {
1063
+ btn.textContent = theme === "dark" ? "☀︎" : "☾";
1064
+ btn.title = theme === "dark" ? "Switch to light mode" : "Switch to dark mode";
1065
+ }
1066
+ }
1067
+
1068
+ (function setupTheme() {
1069
+ applyTheme(currentTheme());
1070
+ var btn = document.getElementById("cp-theme-toggle");
1071
+ if (btn) {
1072
+ btn.addEventListener("click", function () {
1073
+ applyTheme(currentTheme() === "dark" ? "light" : "dark");
1074
+ });
1075
+ }
1076
+ })();
@@ -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
@@ -961,3 +1044,41 @@
961
1044
  opacity: 1;
962
1045
  transform: translateY(0);
963
1046
  }
1047
+
1048
+ /* ════════════════════════════════════════════════════════════════════════════
1049
+ Dark theme overrides.
1050
+ The page already flows through the semantic tokens, so only the semantic
1051
+ color chips that hardcode light pastels need explicit dark variants here.
1052
+ The .cp-theme-toggle button itself is styled in styles.css (shared).
1053
+ ════════════════════════════════════════════════════════════════════════════ */
1054
+ [data-theme="dark"] .cp-status-DRAFT { background: #322d0c; color: #e8d860; border-color: #f4dc0033; }
1055
+ [data-theme="dark"] .cp-status-APPROVED { background: #14301c; color: #8fe39d; border-color: #62dc4433; }
1056
+
1057
+ [data-theme="dark"] .cp-c-badge-success { background: #14301c; color: #8fe39d; border-color: #62dc4433; }
1058
+ [data-theme="dark"] .cp-c-badge-warning { background: #322d0c; color: #e8d860; border-color: #f4dc0033; }
1059
+ [data-theme="dark"] .cp-c-badge-danger { background: #371616; color: #f08f8f; border-color: #f4393933; }
1060
+ [data-theme="dark"] .cp-c-badge-info { background: #0e2c3a; color: #8fd9f5; border-color: #76d6ff33; }
1061
+
1062
+ [data-theme="dark"] .cp-c-callout-info { background: #0e2c3a55; border-color: #76d6ff33; }
1063
+ [data-theme="dark"] .cp-c-callout-success { background: #14301c55; border-color: #62dc4433; }
1064
+ [data-theme="dark"] .cp-c-callout-warning { background: #322d0c55; border-color: #f4dc0033; }
1065
+ [data-theme="dark"] .cp-c-callout-danger { background: #37161655; border-color: #f4393933; }
1066
+ [data-theme="dark"] .cp-c-callout-info .cp-c-callout-icon { color: #8fd9f5; }
1067
+ [data-theme="dark"] .cp-c-callout-success .cp-c-callout-icon { color: #8fe39d; }
1068
+ [data-theme="dark"] .cp-c-callout-warning .cp-c-callout-icon { color: #e8d860; }
1069
+ [data-theme="dark"] .cp-c-callout-danger .cp-c-callout-icon { color: #f08f8f; }
1070
+
1071
+ [data-theme="dark"] .cp-mermaid-error {
1072
+ background: #37161655;
1073
+ border-color: #f4393933;
1074
+ }
1075
+
1076
+ [data-theme="dark"] .cp-pr-badge {
1077
+ background: #0e2c3a;
1078
+ color: #8fd9f5;
1079
+ border-color: #76d6ff33;
1080
+ }
1081
+ [data-theme="dark"] .cp-pr-badge:hover {
1082
+ background: #14384a;
1083
+ color: #b6e8ff;
1084
+ }
@@ -4,6 +4,17 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Dovetail — Claude Plans</title>
7
+ <script>
8
+ /* Apply the saved theme before first paint to avoid a flash of light. */
9
+ (function () {
10
+ try {
11
+ var t = localStorage.getItem("cp-theme");
12
+ if (t === "dark" || t === "light") {
13
+ document.documentElement.setAttribute("data-theme", t);
14
+ }
15
+ } catch (e) {}
16
+ })();
17
+ </script>
7
18
  <link rel="stylesheet" href="tokens.css" />
8
19
  <link rel="stylesheet" href="styles.css" />
9
20
  <link rel="stylesheet" href="claude-plans.css" />
@@ -19,6 +30,8 @@
19
30
  </div>
20
31
  <div class="header-right">
21
32
  <a class="cp-nav-link" href="/">&larr; Update Sets</a>
33
+ <button class="cp-theme-toggle" id="cp-theme-toggle" type="button"
34
+ aria-label="Toggle dark mode" title="Toggle dark mode"></button>
22
35
  <span class="cp-storage" id="cp-storage" title="storage root"></span>
23
36
  </div>
24
37
  </header>
@@ -29,10 +42,32 @@
29
42
  <span class="cp-rail-title">Plans</span>
30
43
  <span class="cp-count" id="cp-count">0</span>
31
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>
32
64
  <div class="cp-rail-empty" id="cp-rail-empty">
33
65
  Waiting for plans. Run <code>push_plan</code> from a Claude Code
34
66
  session to populate this list.
35
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>
36
71
  <ul class="cp-list" id="cp-list"></ul>
37
72
  </aside>
38
73
 
@@ -60,7 +95,7 @@
60
95
 
61
96
  <script src="https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js"></script>
62
97
  <script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.5/dist/purify.min.js"></script>
63
- <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>
64
99
  <script src="claude-plans.js"></script>
65
100
  </body>
66
101
  </html>
@@ -1,15 +1,31 @@
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 () {
8
9
  "use strict";
9
10
 
10
- if (window.mermaid && typeof window.mermaid.initialize === "function") {
11
- window.mermaid.initialize({ startOnLoad: false, theme: "default", securityLevel: "strict" });
11
+ function currentTheme() {
12
+ return document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
12
13
  }
14
+
15
+ function initMermaid() {
16
+ if (window.mermaid && typeof window.mermaid.initialize === "function") {
17
+ window.mermaid.initialize({
18
+ startOnLoad: false,
19
+ theme: currentTheme() === "dark" ? "dark" : "default",
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
24
+ });
25
+ }
26
+ }
27
+ initMermaid();
28
+
13
29
  if (window.marked && typeof window.marked.setOptions === "function") {
14
30
  window.marked.setOptions({ breaks: true, gfm: true });
15
31
  }
@@ -18,7 +34,8 @@
18
34
  plans: new Map(), // slug -> plan
19
35
  artifacts: new Map(), // slug -> Map<artifactSlug, artifact>
20
36
  selectedSlug: null,
21
- activeTab: "plan"
37
+ activeTab: "plan",
38
+ query: "" // lowercased search query; "" means show all
22
39
  };
23
40
 
24
41
  var els = {
@@ -26,6 +43,10 @@
26
43
  list: document.getElementById("cp-list"),
27
44
  count: document.getElementById("cp-count"),
28
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"),
29
50
  detailEmpty: document.getElementById("cp-detail-empty"),
30
51
  detailBody: document.getElementById("cp-detail-body"),
31
52
  detailTitle: document.getElementById("cp-detail-title"),
@@ -37,12 +58,46 @@
37
58
  tabs: document.querySelectorAll(".cp-tab")
38
59
  };
39
60
 
40
- 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() {
41
89
  return Array.from(state.plans.values()).sort(function (a, b) {
42
90
  return (b.updated_at || "").localeCompare(a.updated_at || "");
43
91
  });
44
92
  }
45
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
+
46
101
  function sortedArtifacts(slug) {
47
102
  var map = state.artifacts.get(slug);
48
103
  if (!map) return [];
@@ -87,27 +142,62 @@
87
142
  target.innerHTML = window.DOMPurify.sanitize(html);
88
143
  }
89
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
+
90
172
  function renderMermaid(source, target) {
173
+ target.classList.remove("cp-mermaid-fallback", "cp-mermaid-error");
91
174
  target.classList.add("cp-mermaid");
92
175
  target.textContent = "";
93
176
  if (!window.mermaid || typeof window.mermaid.render !== "function") {
94
- target.textContent = source;
177
+ renderMermaidFallback(source, target);
95
178
  return;
96
179
  }
180
+ var clean = preprocessMermaid(source);
97
181
  var id = "mmd-" + Math.random().toString(36).slice(2, 10);
98
- try {
99
- window.mermaid.render(id, source).then(
182
+ var doRender = function () {
183
+ window.mermaid.render(id, clean).then(
100
184
  function (out) { target.innerHTML = out.svg; },
101
- function (err) {
102
- target.classList.remove("cp-mermaid");
103
- target.classList.add("cp-mermaid-error");
104
- target.textContent = "mermaid error: " + (err && err.message ? err.message : String(err));
105
- }
185
+ function () { renderMermaidFallback(source, target); }
106
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
+ }
107
199
  } catch (err) {
108
- target.classList.remove("cp-mermaid");
109
- target.classList.add("cp-mermaid-error");
110
- target.textContent = "mermaid error: " + err.message;
200
+ renderMermaidFallback(source, target);
111
201
  }
112
202
  }
113
203
 
@@ -191,13 +281,25 @@
191
281
 
192
282
  function renderRail() {
193
283
  var plans = sortedPlans();
194
- 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);
195
289
  if (plans.length === 0) {
196
- els.railEmpty.style.display = "block";
197
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
+ }
198
299
  return;
199
300
  }
200
301
  els.railEmpty.style.display = "none";
302
+ els.railNoMatch.hidden = true;
201
303
  els.list.innerHTML = "";
202
304
  plans.forEach(function (plan) {
203
305
  var artifactCount = (state.artifacts.get(plan.slug) || new Map()).size;
@@ -377,6 +479,69 @@
377
479
  btn.addEventListener("click", function () { setActiveTab(btn.dataset.tab); });
378
480
  });
379
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
+
380
545
  function upsertPlan(plan) {
381
546
  state.plans.set(plan.slug, plan);
382
547
  renderRail();
@@ -472,7 +637,33 @@
472
637
  es.onerror = function () { /* EventSource auto-reconnects */ };
473
638
  }
474
639
 
640
+ /* ─── Theme toggle ─────────────────────────────────────────────────────────── */
641
+
642
+ function applyTheme(theme) {
643
+ document.documentElement.setAttribute("data-theme", theme);
644
+ try { localStorage.setItem("cp-theme", theme); } catch (_) {}
645
+ var btn = document.getElementById("cp-theme-toggle");
646
+ if (btn) {
647
+ btn.textContent = theme === "dark" ? "☀︎" : "☾";
648
+ btn.title = theme === "dark" ? "Switch to light mode" : "Switch to dark mode";
649
+ }
650
+ initMermaid();
651
+ }
652
+
653
+ function setupTheme() {
654
+ applyTheme(currentTheme());
655
+ var btn = document.getElementById("cp-theme-toggle");
656
+ if (btn) {
657
+ btn.addEventListener("click", function () {
658
+ applyTheme(currentTheme() === "dark" ? "light" : "dark");
659
+ if (state.selectedSlug) renderDetail();
660
+ });
661
+ }
662
+ }
663
+
475
664
  document.addEventListener("DOMContentLoaded", function () {
665
+ setupTheme();
666
+ setupSearch();
476
667
  setActiveTab("plan");
477
668
  loadInitial().then(startStream);
478
669
  });
package/public/index.html CHANGED
@@ -4,6 +4,17 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>Dovetail — Update Sets</title>
7
+ <script>
8
+ /* Apply the saved theme before first paint to avoid a flash of light. */
9
+ (function () {
10
+ try {
11
+ var t = localStorage.getItem("cp-theme");
12
+ if (t === "dark" || t === "light") {
13
+ document.documentElement.setAttribute("data-theme", t);
14
+ }
15
+ } catch (e) {}
16
+ })();
17
+ </script>
7
18
  <link rel="stylesheet" href="tokens.css">
8
19
  <link rel="stylesheet" href="styles.css">
9
20
  </head>
@@ -20,6 +31,8 @@
20
31
  <div class="header-right">
21
32
  <button class="btn-refresh" id="refresh-btn" title="Refresh scopes and update sets">Refresh</button>
22
33
  <a class="btn-refresh" href="/claude-plans" title="Claude plans and diagrams pushed via MCP">Claude Plans</a>
34
+ <button class="cp-theme-toggle" id="cp-theme-toggle" type="button"
35
+ aria-label="Toggle dark mode" title="Toggle dark mode"></button>
23
36
  <div class="active-task-chip" id="active-task-chip" style="display:none;"></div>
24
37
  <div class="instance-badge" id="instance-badge">Loading...</div>
25
38
  </div>
package/public/styles.css CHANGED
@@ -887,3 +887,31 @@ header h1 span {
887
887
  .recent-edit-dismiss:hover {
888
888
  color: var(--danger);
889
889
  }
890
+
891
+ /* ── Theme toggle button (header) — shared by dashboard + Claude Plans ──── */
892
+ .cp-theme-toggle {
893
+ display: inline-flex;
894
+ align-items: center;
895
+ justify-content: center;
896
+ width: 32px;
897
+ height: 32px;
898
+ padding: 0;
899
+ font-size: 14px;
900
+ line-height: 1;
901
+ background: var(--surface-raised);
902
+ color: var(--fg-muted);
903
+ border: 1px solid var(--border-strong);
904
+ border-radius: var(--radius-md);
905
+ cursor: pointer;
906
+ transition: background var(--dur-1) var(--ease-out),
907
+ color var(--dur-1) var(--ease-out);
908
+ }
909
+ .cp-theme-toggle:hover {
910
+ background: var(--bg-subtle);
911
+ color: var(--fg);
912
+ }
913
+ .cp-theme-toggle:focus-visible {
914
+ outline: none;
915
+ border-color: var(--border-focus);
916
+ box-shadow: 0 0 0 3px rgba(50, 181, 127, 0.18);
917
+ }
package/public/tokens.css CHANGED
@@ -160,3 +160,64 @@
160
160
  --av-pink-bg: #fb61fe; --av-pink-fg: #fbf8f3;
161
161
  --av-earthy-bg: #dcd4c0; --av-earthy-fg: #987e59;
162
162
  }
163
+
164
+ /* ============================================================================
165
+ Dark theme — activated by <html data-theme="dark">.
166
+ Used by the Claude Plans page; safe to adopt dashboard-wide later.
167
+ Overrides the semantic aliases only — the raw color scales above are shared.
168
+ Brand emerald and neon accent are preserved; grounds invert to dark woodgrain.
169
+ ============================================================================ */
170
+ [data-theme="dark"] {
171
+ --bg: #0d1410;
172
+ --bg-subtle: #121c16;
173
+ --bg-muted: #18241c;
174
+ --bg-paper: #121c16;
175
+ --bg-oak: #20302554;
176
+ --bg-deep: #060b08;
177
+ --bg-overlay: rgba(0, 0, 0, 0.66);
178
+
179
+ --surface: #121c16;
180
+ --surface-raised: #18241c;
181
+
182
+ --fg: #e9efe9;
183
+ --fg-muted: #8da095;
184
+ --fg-subtle: #5f7167;
185
+ --fg-on-dark: #e9efe9;
186
+ --fg-on-neon: var(--emerald-700);
187
+
188
+ --accent: var(--neon-300);
189
+ --accent-hover: var(--neon-500);
190
+ --accent-press: var(--neon-700);
191
+ --accent-fg: #0d1410;
192
+
193
+ --brand: var(--emerald-50);
194
+ --brand-deep: #e9efe9;
195
+
196
+ --success: var(--success-300);
197
+ --warning: var(--warning-300);
198
+ --danger: var(--error-300);
199
+ --danger-hover: var(--error-500);
200
+ --info: var(--blue-300);
201
+
202
+ --border: #26342b;
203
+ --border-strong: #344538;
204
+ --border-subtle: #1d2922;
205
+ --border-focus: var(--neon-500);
206
+
207
+ --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.4);
208
+ --shadow-2: 0 4px 12px rgba(0, 0, 0, 0.5);
209
+ --shadow-3: 0 10px 34px rgba(0, 0, 0, 0.6);
210
+ --shadow-pop: 0 12px 40px rgba(0, 0, 0, 0.7);
211
+
212
+ /* ===== Tag color pairs — dark grounds, light ink ===================== */
213
+ --tag-green-bg: #14301c; --tag-green-fg: #8fe39d;
214
+ --tag-blue-bg: #182040; --tag-blue-fg: #aab4ff;
215
+ --tag-cyan-bg: #0e2c3a; --tag-cyan-fg: #8fd9f5;
216
+ --tag-sage-bg: #1d2922; --tag-sage-fg: #b6c6bc;
217
+ --tag-warm-bg: #2c2417; --tag-warm-fg: #d8c4a2;
218
+ --tag-yellow-bg: #322d0c; --tag-yellow-fg: #e8d860;
219
+ --tag-purple-bg: #2a1633; --tag-purple-fg: #e29bf0;
220
+ --tag-orange-bg: #331c0c; --tag-orange-fg: #f0a878;
221
+ --tag-red-bg: #371616; --tag-red-fg: #f08f8f;
222
+ --tag-teal-bg: #103027; --tag-teal-fg: #6fd1ad;
223
+ }