@jungjaehoon/mama-os 0.62.0 → 0.63.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,272 @@
1
+ /**
2
+ * Memory Timeline Module - what was saved, and when
3
+ * @module modules/memory-timeline
4
+ *
5
+ * The memory view's list: a period (today, 7 days, 30 days or one day), then by day, then by
6
+ * kind, then by work item and its updates. Every filter is a server read, so the list and the
7
+ * counts on the kind chips always describe the same records.
8
+ */
9
+
10
+ /* eslint-env browser */
11
+
12
+ import { escapeAttr, escapeHtml, debounce, getElementByIdOrNull } from '../utils/dom.js';
13
+ import {
14
+ API,
15
+ type MemoryTimelineResponse,
16
+ type TimelineGroup,
17
+ type TimelineItem,
18
+ type TimelineRecord,
19
+ } from '../utils/api.js';
20
+
21
+ /** The groups the server names first, in its order, with the words and colours the list shows. */
22
+ const GROUPS: Record<string, { label: string; hint: string; color: string }> = {
23
+ owner_rule: {
24
+ label: 'Owner rules',
25
+ hint: 'Rules the owner set in their own chat',
26
+ color: '#FB7185',
27
+ },
28
+ learned: {
29
+ label: 'Learned rules',
30
+ hint: 'Lessons, preferences, constraints and workflows learned in other turns',
31
+ color: '#FBBF24',
32
+ },
33
+ decision: { label: 'Decisions', hint: 'Decisions recorded', color: '#60A5FA' },
34
+ fact: { label: 'Facts', hint: 'Facts recorded', color: '#2DD4BF' },
35
+ work: { label: 'Work updates', hint: 'Work items opened or updated', color: '#FB923C' },
36
+ };
37
+
38
+ const VIA_LABELS: Record<string, string> = {
39
+ owner_chat: 'owner chat',
40
+ source_delta: 'source update',
41
+ report: 'report',
42
+ };
43
+
44
+ const OPERATION_LABELS: Record<string, string> = {
45
+ create: 'opened',
46
+ revise: 'updated',
47
+ withdraw: 'withdrawn',
48
+ };
49
+
50
+ function groupInfo(group: string): { label: string; hint: string; color: string } {
51
+ return GROUPS[group] ?? { label: group, hint: `Records of kind ${group}`, color: '#CBD5E1' };
52
+ }
53
+
54
+ function dayLabel(day: string): string {
55
+ return new Intl.DateTimeFormat(undefined, {
56
+ weekday: 'short',
57
+ year: 'numeric',
58
+ month: 'short',
59
+ day: 'numeric',
60
+ timeZone: 'UTC',
61
+ }).format(new Date(`${day}T00:00:00Z`));
62
+ }
63
+
64
+ export class MemoryTimelineModule {
65
+ period = '7d';
66
+ day: string | null = null;
67
+ query = '';
68
+ groups = new Set<string>();
69
+ selectedId: string | null = null;
70
+ private request = 0;
71
+ private bound = false;
72
+ private debouncedLoad = debounce(() => void this.load(), 300);
73
+
74
+ constructor(private readonly onSelect: (id: string) => void) {}
75
+
76
+ /** Wire the controls once, then read the current period. */
77
+ async init(): Promise<void> {
78
+ if (!this.bound) {
79
+ this.bind();
80
+ this.bound = true;
81
+ }
82
+ await this.load();
83
+ }
84
+
85
+ private bind(): void {
86
+ document.querySelectorAll<HTMLButtonElement>('[data-memory-period]').forEach((button) => {
87
+ button.addEventListener('click', () => {
88
+ this.period = button.dataset.memoryPeriod ?? '7d';
89
+ this.day = null;
90
+ const dayInput = getElementByIdOrNull<HTMLInputElement>('memory-day');
91
+ if (dayInput) dayInput.value = '';
92
+ void this.load();
93
+ });
94
+ });
95
+ getElementByIdOrNull<HTMLInputElement>('memory-day')?.addEventListener('change', (event) => {
96
+ const value = (event.target as HTMLInputElement).value;
97
+ this.day = value === '' ? null : value;
98
+ void this.load();
99
+ });
100
+ getElementByIdOrNull<HTMLInputElement>('memory-timeline-search')?.addEventListener(
101
+ 'input',
102
+ (event) => {
103
+ this.query = (event.target as HTMLInputElement).value.trim();
104
+ this.debouncedLoad();
105
+ }
106
+ );
107
+ getElementByIdOrNull<HTMLElement>('memory-group-chips')?.addEventListener('click', (event) => {
108
+ const chip = (event.target as HTMLElement).closest<HTMLButtonElement>('[data-group]');
109
+ if (!chip?.dataset.group) return;
110
+ const group = chip.dataset.group;
111
+ if (this.groups.has(group)) this.groups.delete(group);
112
+ else this.groups.add(group);
113
+ void this.load();
114
+ });
115
+ getElementByIdOrNull<HTMLElement>('memory-timeline')?.addEventListener('click', (event) => {
116
+ const row = (event.target as HTMLElement).closest<HTMLElement>('[data-record-id]');
117
+ if (!row?.dataset.recordId) return;
118
+ this.select(row.dataset.recordId);
119
+ });
120
+ }
121
+
122
+ private select(id: string): void {
123
+ this.selectedId = id;
124
+ document.querySelectorAll('#memory-timeline .mt-row.selected').forEach((row) => {
125
+ row.classList.remove('selected');
126
+ });
127
+ document
128
+ .querySelector(`#memory-timeline [data-record-id="${CSS.escape(id)}"]`)
129
+ ?.classList.add('selected');
130
+ this.onSelect(id);
131
+ }
132
+
133
+ private params(): Record<string, string> {
134
+ return {
135
+ ...(this.day === null ? { period: this.period } : { from: this.day, to: this.day }),
136
+ ...(this.query === '' ? {} : { q: this.query }),
137
+ ...(this.groups.size === 0 ? {} : { groups: [...this.groups].join(',') }),
138
+ };
139
+ }
140
+
141
+ async load(): Promise<void> {
142
+ const list = getElementByIdOrNull<HTMLElement>('memory-timeline');
143
+ if (!list) return;
144
+ const request = ++this.request;
145
+ list.setAttribute('aria-busy', 'true');
146
+ try {
147
+ const data = await API.getMemoryTimeline(this.params());
148
+ // A slower earlier read must not overwrite the answer to the latest filter.
149
+ if (request !== this.request) return;
150
+ this.render(data);
151
+ } catch (error) {
152
+ if (request !== this.request) return;
153
+ list.innerHTML = `<div class="mt-empty mt-error">Could not read what was saved: ${escapeHtml(
154
+ error instanceof Error ? error.message : String(error)
155
+ )}</div>`;
156
+ } finally {
157
+ if (request === this.request) list.removeAttribute('aria-busy');
158
+ }
159
+ }
160
+
161
+ render(data: MemoryTimelineResponse): void {
162
+ document.querySelectorAll<HTMLButtonElement>('[data-memory-period]').forEach((button) => {
163
+ button.setAttribute(
164
+ 'aria-pressed',
165
+ String(this.day === null && button.dataset.memoryPeriod === this.period)
166
+ );
167
+ });
168
+ const range = getElementByIdOrNull<HTMLElement>('memory-timeline-range');
169
+ if (range) {
170
+ range.textContent =
171
+ data.from === data.to
172
+ ? dayLabel(data.from)
173
+ : `${dayLabel(data.from)} – ${dayLabel(data.to)}`;
174
+ }
175
+ const count = getElementByIdOrNull<HTMLElement>('memory-timeline-count');
176
+ if (count) count.textContent = `${data.total} saved`;
177
+ this.renderChips(data.counts);
178
+
179
+ const list = getElementByIdOrNull<HTMLElement>('memory-timeline');
180
+ if (!list) return;
181
+ if (data.days.length === 0) {
182
+ list.innerHTML = `<div class="mt-empty">Nothing saved${
183
+ this.query === '' ? '' : ` matching “${escapeHtml(this.query)}”`
184
+ } in this period.</div>`;
185
+ return;
186
+ }
187
+ list.innerHTML = data.days
188
+ .map(
189
+ (day) =>
190
+ `<section class="mt-day"><h3 class="mt-day-head"><span>${escapeHtml(
191
+ dayLabel(day.day)
192
+ )}</span><span class="mt-count">${day.total}</span></h3>${day.groups
193
+ .map((group) => this.renderGroup(group))
194
+ .join('')}</section>`
195
+ )
196
+ .join('');
197
+ if (this.selectedId !== null) {
198
+ document
199
+ .querySelector(`#memory-timeline [data-record-id="${CSS.escape(this.selectedId)}"]`)
200
+ ?.classList.add('selected');
201
+ }
202
+ }
203
+
204
+ private renderChips(counts: Record<string, number>): void {
205
+ const container = getElementByIdOrNull<HTMLElement>('memory-group-chips');
206
+ if (!container) return;
207
+ container.innerHTML = Object.entries(counts)
208
+ .filter(([group, count]) => count > 0 || this.groups.has(group))
209
+ .map(([group, count]) => {
210
+ const info = groupInfo(group);
211
+ return `<button type="button" class="memory-chip" data-group="${escapeAttr(
212
+ group
213
+ )}" aria-pressed="${this.groups.has(group)}" title="${escapeAttr(
214
+ info.hint
215
+ )}"><span class="mt-dot" style="background:${info.color}"></span>${escapeHtml(
216
+ info.label
217
+ )} <span class="mt-count">${count}</span></button>`;
218
+ })
219
+ .join('');
220
+ }
221
+
222
+ private renderGroup(group: TimelineGroup): string {
223
+ const info = groupInfo(group.group);
224
+ const body =
225
+ group.items !== undefined
226
+ ? group.items.map((item) => this.renderItem(item)).join('')
227
+ : (group.records ?? []).map((record) => this.renderRecord(record)).join('');
228
+ return `<details class="mt-group" open><summary class="mt-group-head" title="${escapeAttr(
229
+ info.hint
230
+ )}"><span class="mt-dot" style="background:${info.color}"></span>${escapeHtml(
231
+ info.label
232
+ )}<span class="mt-count">${group.count}</span></summary>${body}</details>`;
233
+ }
234
+
235
+ private renderRecord(record: TimelineRecord): string {
236
+ const via = record.via === null ? '' : (VIA_LABELS[record.via] ?? record.via);
237
+ const status =
238
+ record.status === null || record.status === 'active'
239
+ ? ''
240
+ : `<span class="mt-badge">${escapeHtml(record.status)}</span>`;
241
+ return `<button type="button" class="mt-row" data-record-id="${escapeAttr(
242
+ record.id
243
+ )}"><span class="mt-time">${escapeHtml(record.time)}</span><span class="mt-body"><span class="mt-topic">${escapeHtml(
244
+ record.topic
245
+ )}${status}</span><span class="mt-summary">${escapeHtml(record.summary)}</span>${
246
+ via === '' ? '' : `<span class="mt-via">from ${escapeHtml(via)}</span>`
247
+ }</span></button>`;
248
+ }
249
+
250
+ private renderItem(item: TimelineItem): string {
251
+ const latest = item.revisions[0];
252
+ const updates = item.revisions.length;
253
+ return `<details class="mt-item"><summary class="mt-item-head"><span class="mt-time">${escapeHtml(
254
+ latest?.time ?? ''
255
+ )}</span><span class="mt-body"><span class="mt-topic">${escapeHtml(
256
+ item.title ?? item.topic
257
+ )}</span><span class="mt-summary">${escapeHtml(latest?.summary ?? '')}</span></span><span class="mt-count">${updates} ${
258
+ updates === 1 ? 'update' : 'updates'
259
+ }</span></summary>${item.revisions
260
+ .map(
261
+ (revision) =>
262
+ `<button type="button" class="mt-row mt-revision" data-record-id="${escapeAttr(
263
+ revision.id
264
+ )}"><span class="mt-time">${escapeHtml(revision.time)}</span><span class="mt-body"><span class="mt-op">${escapeHtml(
265
+ OPERATION_LABELS[revision.operation ?? ''] ?? revision.operation ?? ''
266
+ )}${revision.revision === null ? '' : ` · revision ${revision.revision}`}</span><span class="mt-summary">${escapeHtml(
267
+ revision.summary
268
+ )}</span></span></button>`
269
+ )
270
+ .join('')}</details>`;
271
+ }
272
+ }
@@ -79,7 +79,7 @@ declare global {
79
79
 
80
80
  interface VisNetwork {
81
81
  on(
82
- event: 'click' | 'stabilized' | 'stabilizationIterationsDone',
82
+ event: 'click' | 'doubleClick' | 'stabilized' | 'stabilizationIterationsDone',
83
83
  handler: (params: { nodes: Array<string | number> }) => void
84
84
  ): void;
85
85
  on(event: 'hoverNode' | 'blurNode', handler: (params: { node: string | number }) => void): void;
@@ -50,22 +50,45 @@ export interface GraphDetailResponse {
50
50
  [key: string]: unknown;
51
51
  }
52
52
 
53
- export interface SimilarDecision {
54
- id?: string | number;
55
- topic?: string;
56
- decision?: string;
57
- reasoning?: string;
58
- relationship?: string;
59
- similarity?: number;
60
- [key: string]: unknown;
53
+ export interface TimelineRecord {
54
+ id: string;
55
+ kind: string | null;
56
+ status: string | null;
57
+ topic: string;
58
+ summary: string;
59
+ time: string;
60
+ via: string | null;
61
61
  }
62
62
 
63
- export interface SimilarDecisionsResponse {
64
- similar: SimilarDecision[];
65
- [key: string]: unknown;
63
+ export interface TimelineItem {
64
+ commitmentId: string;
65
+ title: string | null;
66
+ topic: string;
67
+ revisions: Array<{
68
+ id: string;
69
+ revision: number | null;
70
+ operation: string | null;
71
+ status: string | null;
72
+ summary: string;
73
+ time: string;
74
+ }>;
75
+ }
76
+
77
+ export interface TimelineGroup {
78
+ group: string;
79
+ count: number;
80
+ records?: TimelineRecord[];
81
+ items?: TimelineItem[];
66
82
  }
67
83
 
68
- export type GraphSimilarResponse = GraphResponse & SimilarDecisionsResponse;
84
+ export interface MemoryTimelineResponse {
85
+ from: string;
86
+ to: string;
87
+ timeZone: string;
88
+ total: number;
89
+ counts: Record<string, number>;
90
+ days: Array<{ day: string; total: number; groups: TimelineGroup[] }>;
91
+ }
69
92
 
70
93
  export interface CheckpointSummary {
71
94
  id?: string;
@@ -660,45 +683,18 @@ export class API {
660
683
  // Graph API
661
684
  // =============================================
662
685
 
663
- /**
664
- * Get graph data
665
- * @param {Object} params - Query parameters
666
- * @returns {Promise<Object>} Graph data
667
- */
668
- static async getGraph(params: QueryParams = {}): Promise<GraphResponse> {
669
- // cluster: false by default to avoid slow embedding calculations
670
- return this.get<GraphResponse>('/graph', { cluster: 'false', ...params });
686
+ /** One record and the records and source messages it links to directly. */
687
+ static async getGraphNeighbors(id: string): Promise<GraphResponse> {
688
+ return this.get<GraphResponse>('/api/graph/neighbors', { id });
671
689
  }
672
690
 
673
- static async getGraphDetail(nodeId: string): Promise<GraphDetailResponse> {
674
- return this.get<GraphDetailResponse>('/graph/detail', { id: nodeId });
691
+ /** What was saved in a period, by day, kind and item. */
692
+ static async getMemoryTimeline(params: QueryParams): Promise<MemoryTimelineResponse> {
693
+ return this.get<MemoryTimelineResponse>('/api/memory/timeline', params);
675
694
  }
676
695
 
677
- /**
678
- * Get similar decisions for a node
679
- * @param {string} nodeId - Node ID
680
- * @returns {Promise<Object>} Similar decisions
681
- */
682
- static async getSimilarDecisions(nodeId: string): Promise<GraphSimilarResponse> {
683
- return this.get<GraphSimilarResponse>('/graph/similar', { id: nodeId });
684
- }
685
-
686
- /**
687
- * Update decision outcome
688
- * @param {string} id - Decision ID
689
- * @param {string} outcome - Outcome value
690
- * @param {string} reason - Optional reason
691
- * @returns {Promise<Object>} Update result
692
- */
693
- static async updateOutcome(
694
- id: string,
695
- outcome: string,
696
- reason: string | null = null
697
- ): Promise<JsonRecord> {
698
- return this.post<JsonRecord, { id: string; outcome: string; reason: string | null }>(
699
- '/graph/update',
700
- { id, outcome, reason }
701
- );
696
+ static async getGraphDetail(nodeId: string): Promise<GraphDetailResponse> {
697
+ return this.get<GraphDetailResponse>('/graph/detail', { id: nodeId });
702
698
  }
703
699
 
704
700
  // =============================================
@@ -115,16 +115,16 @@ button:not([class*="p-"]):not([class*="px-"]):not([class*="py-"]) { padding: 6px
115
115
  max-width: 100% !important;
116
116
  border-right: none;
117
117
  border-bottom: 1px solid #e5e7eb;
118
- max-height: 35vh;
118
+ max-height: 55vh;
119
119
  }
120
120
  #sidebar-resize-handle {
121
121
  display: none;
122
122
  }
123
123
  #graph-container {
124
- min-height: 45vh;
124
+ min-height: 40vh;
125
125
  }
126
126
  #graph-canvas {
127
- min-height: 45vh;
127
+ min-height: 40vh;
128
128
  }
129
129
  }
130
130
  @keyframes float {
@@ -255,14 +255,264 @@ button:not([class*="p-"]):not([class*="px-"]):not([class*="py-"]) { padding: 6px
255
255
  flex-wrap: wrap;
256
256
  }
257
257
 
258
- .memory-controls #topic-filter {
258
+ .memory-controls #memory-timeline-search {
259
259
  min-width: 0;
260
- flex: 0 1 10rem;
260
+ flex: 1 1 12rem;
261
+ }
262
+
263
+ /* ============================================================================
264
+ MEMORY TIMELINE - period, kinds, then day > kind > item > update
265
+ ============================================================================ */
266
+
267
+ .memory-periods,
268
+ .memory-groups {
269
+ display: flex;
270
+ flex-wrap: wrap;
271
+ align-items: center;
272
+ gap: 4px;
273
+ }
274
+
275
+ .memory-chip {
276
+ display: inline-flex;
277
+ align-items: center;
278
+ gap: 6px;
279
+ padding: 3px 10px;
280
+ border: 1px solid #e5e1d8;
281
+ border-radius: 9999px;
282
+ background: #fff;
283
+ color: #3f3a35;
284
+ font-size: 12px;
285
+ line-height: 18px;
286
+ cursor: pointer;
261
287
  }
262
288
 
263
- .memory-controls #search-input {
289
+ .memory-chip:hover {
290
+ background: #fffaf0;
291
+ }
292
+
293
+ .memory-chip[aria-pressed='true'] {
294
+ background: #131313;
295
+ border-color: #131313;
296
+ color: #fff;
297
+ }
298
+
299
+ .memory-chip[aria-pressed='true'] .mt-count {
300
+ color: #d4d0c8;
301
+ }
302
+
303
+ .memory-day {
304
+ padding: 2px 6px;
305
+ border: 1px solid #e5e1d8;
306
+ border-radius: 6px;
307
+ font-size: 12px;
308
+ color: #3f3a35;
309
+ }
310
+
311
+ .mt-head {
312
+ display: flex;
313
+ align-items: baseline;
314
+ justify-content: space-between;
315
+ gap: 8px;
316
+ padding: 8px 12px;
317
+ border-bottom: 1px solid #ede9e1;
318
+ }
319
+
320
+ .mt-range {
321
+ font-size: 13px;
322
+ font-weight: 600;
323
+ color: #1a1a1a;
324
+ }
325
+
326
+ .mt-total,
327
+ .mt-count {
328
+ font-size: 12px;
329
+ color: #8a847c;
330
+ font-variant-numeric: tabular-nums;
331
+ }
332
+
333
+ .mt-empty {
334
+ padding: 24px 16px;
335
+ text-align: center;
336
+ font-size: 13px;
337
+ color: #8a847c;
338
+ }
339
+
340
+ .mt-error {
341
+ color: #b42318;
342
+ }
343
+
344
+ #memory-timeline[aria-busy='true'] {
345
+ opacity: 0.6;
346
+ }
347
+
348
+ .mt-day-head {
349
+ position: sticky;
350
+ top: 0;
351
+ z-index: 1;
352
+ display: flex;
353
+ justify-content: space-between;
354
+ padding: 8px 12px;
355
+ background: #f7f5f0;
356
+ border-bottom: 1px solid #ede9e1;
357
+ font-size: 13px;
358
+ font-weight: 600;
359
+ color: #1a1a1a;
360
+ }
361
+
362
+ .mt-group-head,
363
+ .mt-item-head {
364
+ display: flex;
365
+ align-items: center;
366
+ gap: 8px;
367
+ cursor: pointer;
368
+ list-style: none;
369
+ }
370
+
371
+ .mt-group-head::-webkit-details-marker,
372
+ .mt-item-head::-webkit-details-marker {
373
+ display: none;
374
+ }
375
+
376
+ .mt-group-head {
377
+ padding: 6px 12px;
378
+ font-size: 12px;
379
+ font-weight: 600;
380
+ color: #3f3a35;
381
+ }
382
+
383
+ .mt-group-head .mt-count {
384
+ margin-left: auto;
385
+ }
386
+
387
+ .mt-dot {
388
+ width: 10px;
389
+ height: 10px;
390
+ border-radius: 9999px;
391
+ flex-shrink: 0;
392
+ }
393
+
394
+ .mt-row,
395
+ .mt-item-head {
396
+ display: flex;
397
+ align-items: flex-start;
398
+ gap: 10px;
399
+ width: 100%;
400
+ padding: 6px 12px 6px 30px;
401
+ border: 0;
402
+ border-bottom: 1px solid #f5f2ed;
403
+ background: transparent;
404
+ text-align: left;
405
+ cursor: pointer;
406
+ }
407
+
408
+ .mt-row:hover,
409
+ .mt-item-head:hover {
410
+ background: #fffdf5;
411
+ }
412
+
413
+ .mt-row.selected {
414
+ background: #fff4cc;
415
+ }
416
+
417
+ .mt-revision {
418
+ padding-left: 54px;
419
+ }
420
+
421
+ .mt-time {
422
+ flex-shrink: 0;
423
+ width: 40px;
424
+ font-size: 12px;
425
+ color: #8a847c;
426
+ font-variant-numeric: tabular-nums;
427
+ }
428
+
429
+ .mt-body {
430
+ display: flex;
431
+ flex-direction: column;
432
+ gap: 2px;
264
433
  min-width: 0;
265
- flex: 1 1 12rem;
434
+ flex: 1;
435
+ }
436
+
437
+ .mt-topic,
438
+ .mt-op {
439
+ font-size: 13px;
440
+ font-weight: 600;
441
+ color: #1a1a1a;
442
+ overflow-wrap: anywhere;
443
+ }
444
+
445
+ .mt-op {
446
+ font-weight: 500;
447
+ color: #6b6560;
448
+ }
449
+
450
+ .mt-summary {
451
+ font-size: 12px;
452
+ line-height: 1.45;
453
+ color: #4a4540;
454
+ display: -webkit-box;
455
+ -webkit-line-clamp: 2;
456
+ -webkit-box-orient: vertical;
457
+ overflow: hidden;
458
+ }
459
+
460
+ .mt-via {
461
+ font-size: 11px;
462
+ color: #8a847c;
463
+ }
464
+
465
+ .mt-badge {
466
+ margin-left: 6px;
467
+ padding: 0 6px;
468
+ border-radius: 9999px;
469
+ background: #f1eee8;
470
+ font-size: 11px;
471
+ font-weight: 500;
472
+ color: #6b6560;
473
+ }
474
+
475
+ .mt-item-head .mt-count {
476
+ flex-shrink: 0;
477
+ }
478
+
479
+ .graph-empty-text {
480
+ max-width: 320px;
481
+ text-align: center;
482
+ font-size: 14px;
483
+ color: #8a847c;
484
+ }
485
+
486
+ .legend-heading {
487
+ margin-bottom: 6px;
488
+ font-size: 12px;
489
+ font-weight: 600;
490
+ color: #3f3a35;
491
+ }
492
+
493
+ .legend-row {
494
+ display: flex;
495
+ align-items: center;
496
+ gap: 8px;
497
+ margin-bottom: 4px;
498
+ font-size: 13px;
499
+ color: #1a1a1a;
500
+ }
501
+
502
+ .legend-label {
503
+ flex: 1;
504
+ }
505
+
506
+ .legend-count {
507
+ color: #8a847c;
508
+ font-variant-numeric: tabular-nums;
509
+ }
510
+
511
+ .legend-dot {
512
+ width: 12px;
513
+ height: 12px;
514
+ border-radius: 9999px;
515
+ flex-shrink: 0;
266
516
  }
267
517
 
268
518
  /* ============================================================================