@tenonhq/dovetail-dashboard 0.0.22 → 0.0.23

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.22",
3
+ "version": "0.0.23",
4
4
  "description": "Update Set Dashboard for Dovetail",
5
5
  "main": "server.js",
6
6
  "scripts": {
@@ -1123,3 +1123,138 @@
1123
1123
  background: #14384a;
1124
1124
  color: #b6e8ff;
1125
1125
  }
1126
+
1127
+ /* ─── Topics cloud ─────────────────────────────────────────────────────────── */
1128
+
1129
+ .cp-topics {
1130
+ margin: 0 24px 12px;
1131
+ background: var(--surface-raised);
1132
+ border: 1px solid var(--border);
1133
+ border-radius: var(--radius-md, 8px);
1134
+ box-shadow: var(--shadow-1, 0 1px 2px rgba(0,0,0,0.04));
1135
+ }
1136
+
1137
+ .cp-topics-summary {
1138
+ display: flex;
1139
+ align-items: center;
1140
+ gap: 8px;
1141
+ padding: 10px 14px;
1142
+ cursor: pointer;
1143
+ list-style: none;
1144
+ user-select: none;
1145
+ font-size: 13px;
1146
+ color: var(--fg);
1147
+ }
1148
+ .cp-topics-summary::-webkit-details-marker { display: none; }
1149
+ .cp-topics-summary:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); }
1150
+
1151
+ .cp-topics-caret {
1152
+ display: inline-block;
1153
+ transition: transform 0.15s ease;
1154
+ color: var(--fg-muted);
1155
+ font-size: 12px;
1156
+ width: 12px;
1157
+ }
1158
+ .cp-topics:not([open]) .cp-topics-caret { transform: rotate(-90deg); }
1159
+
1160
+ .cp-topics-title {
1161
+ font-weight: 600;
1162
+ letter-spacing: 0.02em;
1163
+ }
1164
+
1165
+ .cp-topics-count {
1166
+ display: inline-flex;
1167
+ align-items: center;
1168
+ justify-content: center;
1169
+ min-width: 22px;
1170
+ height: 20px;
1171
+ padding: 0 6px;
1172
+ background: var(--bg-muted, var(--surface));
1173
+ color: var(--fg-muted);
1174
+ border: 1px solid var(--border);
1175
+ border-radius: 999px;
1176
+ font-size: 11px;
1177
+ font-weight: 600;
1178
+ }
1179
+
1180
+ .cp-topics-clear {
1181
+ margin-left: auto;
1182
+ background: transparent;
1183
+ border: 1px solid var(--border);
1184
+ border-radius: 6px;
1185
+ padding: 3px 10px;
1186
+ font-size: 11px;
1187
+ color: var(--fg-muted);
1188
+ cursor: pointer;
1189
+ }
1190
+ .cp-topics-clear:hover {
1191
+ background: var(--bg-muted, var(--surface));
1192
+ color: var(--fg);
1193
+ }
1194
+
1195
+ .cp-topics-cloud {
1196
+ display: flex;
1197
+ flex-wrap: wrap;
1198
+ gap: 6px 8px;
1199
+ padding: 10px 14px 14px;
1200
+ border-top: 1px solid var(--border-subtle, var(--border));
1201
+ line-height: 1.3;
1202
+ }
1203
+
1204
+ .cp-topics-empty {
1205
+ color: var(--fg-muted);
1206
+ font-size: 12px;
1207
+ font-style: italic;
1208
+ padding: 2px 0;
1209
+ }
1210
+
1211
+ .cp-topic-chip {
1212
+ display: inline-flex;
1213
+ align-items: baseline;
1214
+ gap: 6px;
1215
+ padding: 4px 10px;
1216
+ background: var(--surface);
1217
+ color: var(--fg);
1218
+ border: 1px solid var(--border);
1219
+ border-radius: 999px;
1220
+ font-family: inherit;
1221
+ font-weight: 500;
1222
+ letter-spacing: 0.01em;
1223
+ cursor: pointer;
1224
+ transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
1225
+ }
1226
+ .cp-topic-chip:hover {
1227
+ background: color-mix(in srgb, var(--accent) 10%, var(--surface));
1228
+ border-color: var(--accent);
1229
+ }
1230
+ .cp-topic-chip:focus-visible {
1231
+ outline: none;
1232
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 35%, transparent);
1233
+ }
1234
+
1235
+ .cp-topic-chip--active {
1236
+ background: var(--accent);
1237
+ color: var(--accent-fg);
1238
+ border-color: var(--accent);
1239
+ }
1240
+ .cp-topic-chip--active:hover {
1241
+ background: var(--accent-hover, var(--accent));
1242
+ color: var(--accent-fg);
1243
+ }
1244
+
1245
+ .cp-topic-chip-count {
1246
+ display: inline-flex;
1247
+ align-items: center;
1248
+ justify-content: center;
1249
+ min-width: 18px;
1250
+ padding: 0 5px;
1251
+ background: color-mix(in srgb, currentColor 12%, transparent);
1252
+ border-radius: 999px;
1253
+ font-size: 10px;
1254
+ font-weight: 700;
1255
+ line-height: 1.6;
1256
+ letter-spacing: 0;
1257
+ }
1258
+ .cp-topic-chip--active .cp-topic-chip-count {
1259
+ background: color-mix(in srgb, var(--accent-fg) 20%, transparent);
1260
+ }
@@ -36,6 +36,27 @@
36
36
  </div>
37
37
  </header>
38
38
 
39
+ <details class="cp-topics" id="cp-topics" open>
40
+ <summary class="cp-topics-summary">
41
+ <span class="cp-topics-caret" aria-hidden="true">▾</span>
42
+ <span class="cp-topics-title">Topics</span>
43
+ <span class="cp-topics-count" id="cp-topics-count">0</span>
44
+ <button
45
+ type="button"
46
+ class="cp-topics-clear"
47
+ id="cp-topics-clear"
48
+ hidden
49
+ title="Clear topic filter"
50
+ aria-label="Clear topic filter"
51
+ >Clear filter</button>
52
+ </summary>
53
+ <div class="cp-topics-cloud" id="cp-topics-cloud" role="group" aria-label="Plan topics">
54
+ <span class="cp-topics-empty" id="cp-topics-empty">
55
+ No topics yet. Push a plan and topics will appear here.
56
+ </span>
57
+ </div>
58
+ </details>
59
+
39
60
  <main class="cp-layout">
40
61
  <aside class="cp-rail" id="cp-rail">
41
62
  <div class="cp-rail-header">
@@ -36,7 +36,8 @@
36
36
  prompts: new Map(), // slug -> Map<promptSlug, prompt>
37
37
  selectedSlug: null,
38
38
  activeTab: "plan",
39
- query: "" // lowercased search query; "" means show all
39
+ query: "", // lowercased search query; "" means show all
40
+ topicFilter: null // active topic label, null = no topic filter
40
41
  };
41
42
 
42
43
  var els = {
@@ -58,9 +59,24 @@
58
59
  planPanel: document.getElementById("cp-tab-plan"),
59
60
  artifactsPanel: document.getElementById("cp-tab-artifacts"),
60
61
  promptsPanel: document.getElementById("cp-tab-prompts"),
61
- tabs: document.querySelectorAll(".cp-tab")
62
+ tabs: document.querySelectorAll(".cp-tab"),
63
+ topics: document.getElementById("cp-topics"),
64
+ topicsCloud: document.getElementById("cp-topics-cloud"),
65
+ topicsCount: document.getElementById("cp-topics-count"),
66
+ topicsEmpty: document.getElementById("cp-topics-empty"),
67
+ topicsClear: document.getElementById("cp-topics-clear")
62
68
  };
63
69
 
70
+ function planMatchesTopic(plan, topic) {
71
+ if (!topic) return true;
72
+ var cats = plan.categories;
73
+ if (!cats || !cats.length) return false;
74
+ for (var i = 0; i < cats.length; i++) {
75
+ if (cats[i] === topic) return true;
76
+ }
77
+ return false;
78
+ }
79
+
64
80
  // True when plan matches q across title, slug, status, plan markdown/html,
65
81
  // pr title, and every artifact title + content. Lets you find a plan by any
66
82
  // snippet that appears anywhere in or attached to it.
@@ -96,9 +112,121 @@
96
112
 
97
113
  function sortedPlans() {
98
114
  var q = state.query;
115
+ var topic = state.topicFilter;
99
116
  var all = allPlansSorted();
100
- if (!q) return all;
101
- return all.filter(function (p) { return planMatchesQuery(p, q); });
117
+ if (!q && !topic) return all;
118
+ return all.filter(function (p) {
119
+ return planMatchesQuery(p, q) && planMatchesTopic(p, topic);
120
+ });
121
+ }
122
+
123
+ /* ─── Topics aggregation + render ──────────────────────────────────────────
124
+ * Counts topic occurrences across all plans (not just filtered ones) so the
125
+ * cloud reflects the full corpus. Click a chip to filter the rail by that
126
+ * topic; click the active chip again to clear.
127
+ *
128
+ * Counts are computed client-side from plan.categories arrays — no new
129
+ * server endpoint or SSE event. The existing plan:upsert stream rerenders
130
+ * the cloud automatically because renderRail() now also calls renderTopics().
131
+ */
132
+ function aggregateTopics() {
133
+ var counts = new Map();
134
+ state.plans.forEach(function (plan) {
135
+ var cats = plan.categories;
136
+ if (!cats || !cats.length) return;
137
+ for (var i = 0; i < cats.length; i++) {
138
+ var label = cats[i];
139
+ counts.set(label, (counts.get(label) || 0) + 1);
140
+ }
141
+ });
142
+ var rows = [];
143
+ counts.forEach(function (count, label) {
144
+ rows.push({ label: label, count: count });
145
+ });
146
+ rows.sort(function (a, b) {
147
+ if (b.count !== a.count) return b.count - a.count;
148
+ return a.label.localeCompare(b.label);
149
+ });
150
+ return rows;
151
+ }
152
+
153
+ // sqrt compression keeps a 30-count topic from dwarfing a 2-count one.
154
+ function topicFontSize(count, max) {
155
+ if (max <= 1) return 14;
156
+ var ratio = Math.sqrt(count) / Math.sqrt(max);
157
+ return Math.round(12 + ratio * 12);
158
+ }
159
+
160
+ function renderTopics() {
161
+ if (!els.topicsCloud) return;
162
+ var rows = aggregateTopics();
163
+ if (els.topicsCount) els.topicsCount.textContent = String(rows.length);
164
+ if (els.topicsClear) els.topicsClear.hidden = !state.topicFilter;
165
+ if (rows.length === 0) {
166
+ els.topicsCloud.innerHTML = "";
167
+ if (els.topicsEmpty) {
168
+ els.topicsCloud.appendChild(els.topicsEmpty);
169
+ els.topicsEmpty.style.display = "inline";
170
+ }
171
+ return;
172
+ }
173
+ if (els.topicsEmpty) els.topicsEmpty.style.display = "none";
174
+ els.topicsCloud.innerHTML = "";
175
+ var max = rows[0].count;
176
+ rows.forEach(function (row) {
177
+ var chip = document.createElement("button");
178
+ chip.type = "button";
179
+ var isActive = state.topicFilter === row.label;
180
+ chip.className = "cp-topic-chip" + (isActive ? " cp-topic-chip--active" : "");
181
+ chip.style.fontSize = topicFontSize(row.count, max) + "px";
182
+ chip.setAttribute("aria-pressed", isActive ? "true" : "false");
183
+ chip.title = row.count + " plan" + (row.count === 1 ? "" : "s");
184
+ var labelEl = document.createElement("span");
185
+ labelEl.className = "cp-topic-chip-label";
186
+ labelEl.textContent = row.label;
187
+ var countEl = document.createElement("span");
188
+ countEl.className = "cp-topic-chip-count";
189
+ countEl.textContent = String(row.count);
190
+ chip.appendChild(labelEl);
191
+ chip.appendChild(countEl);
192
+ chip.addEventListener("click", function () {
193
+ applyTopicFilter(state.topicFilter === row.label ? null : row.label);
194
+ });
195
+ els.topicsCloud.appendChild(chip);
196
+ });
197
+ }
198
+
199
+ function applyTopicFilter(label) {
200
+ state.topicFilter = label;
201
+ renderTopics();
202
+ renderRail();
203
+ if (label) {
204
+ var visible = sortedPlans();
205
+ var stillVisible = state.selectedSlug &&
206
+ visible.some(function (p) { return p.slug === state.selectedSlug; });
207
+ if (!stillVisible && visible.length > 0) selectPlan(visible[0].slug);
208
+ }
209
+ }
210
+
211
+ function setupTopics() {
212
+ if (els.topicsClear) {
213
+ els.topicsClear.addEventListener("click", function (e) {
214
+ e.preventDefault();
215
+ e.stopPropagation();
216
+ applyTopicFilter(null);
217
+ });
218
+ }
219
+ if (els.topics) {
220
+ try {
221
+ var stored = localStorage.getItem("cp-topics-collapsed");
222
+ if (stored === "1") els.topics.open = false;
223
+ } catch (_) {}
224
+ els.topics.addEventListener("toggle", function () {
225
+ try {
226
+ localStorage.setItem("cp-topics-collapsed", els.topics.open ? "0" : "1");
227
+ } catch (_) {}
228
+ });
229
+ }
102
230
  }
103
231
 
104
232
  function sortedArtifacts(slug) {
@@ -296,9 +424,10 @@
296
424
  /* ─────────────────────────────────────────────────────────────────────────── */
297
425
 
298
426
  function renderRail() {
427
+ renderTopics();
299
428
  var plans = sortedPlans();
300
429
  var totalPlans = state.plans.size;
301
- var isFiltering = !!state.query;
430
+ var isFiltering = !!state.query || !!state.topicFilter;
302
431
  els.count.textContent = isFiltering
303
432
  ? plans.length + " / " + totalPlans
304
433
  : String(totalPlans);
@@ -307,7 +436,12 @@
307
436
  if (isFiltering && totalPlans > 0) {
308
437
  els.railEmpty.style.display = "none";
309
438
  els.railNoMatch.hidden = false;
310
- if (els.railNoMatchQ) els.railNoMatchQ.textContent = '"' + state.query + '"';
439
+ if (els.railNoMatchQ) {
440
+ var pieces = [];
441
+ if (state.query) pieces.push('"' + state.query + '"');
442
+ if (state.topicFilter) pieces.push("topic “" + state.topicFilter + "”");
443
+ els.railNoMatchQ.textContent = pieces.join(" + ");
444
+ }
311
445
  } else {
312
446
  els.railEmpty.style.display = "block";
313
447
  els.railNoMatch.hidden = true;
@@ -800,6 +934,7 @@
800
934
  document.addEventListener("DOMContentLoaded", function () {
801
935
  setupTheme();
802
936
  setupSearch();
937
+ setupTopics();
803
938
  setActiveTab("plan");
804
939
  loadInitial().then(startStream);
805
940
  });