@countermeasure-platform/web-components 1.3.5-dev.36.1 → 1.3.6-dev.37.1

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.
Files changed (82) hide show
  1. package/dist/{component-D5sRm1fq.js → component-D_asjmrt.js} +5 -3
  2. package/dist/component-D_asjmrt.js.map +1 -0
  3. package/dist/components/brand/index.d.ts.map +1 -1
  4. package/dist/components/brand/index.js +11 -5
  5. package/dist/components/brand/index.js.map +1 -1
  6. package/dist/components/brand/types.d.ts +2 -0
  7. package/dist/components/brand/types.d.ts.map +1 -1
  8. package/dist/icons/index.d.ts +7 -2
  9. package/dist/icons/index.d.ts.map +1 -1
  10. package/dist/icons/index.js +7 -2
  11. package/dist/icons/index.js.map +1 -1
  12. package/dist/icons/lucide.d.ts +3 -0
  13. package/dist/icons/lucide.d.ts.map +1 -1
  14. package/dist/icons/lucide.js +3 -0
  15. package/dist/icons/lucide.js.map +1 -1
  16. package/dist/index.d.ts +1 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +126 -125
  19. package/dist/layout/app-shell.d.ts +7 -0
  20. package/dist/layout/app-shell.d.ts.map +1 -1
  21. package/dist/layout/app-shell.js +24 -6
  22. package/dist/layout/app-shell.js.map +1 -1
  23. package/dist/layout/core-app-chrome.d.ts.map +1 -1
  24. package/dist/layout/core-app-chrome.js +8 -5
  25. package/dist/layout/core-app-chrome.js.map +1 -1
  26. package/dist/layout/core-app-library-dashboard.d.ts +72 -0
  27. package/dist/layout/core-app-library-dashboard.d.ts.map +1 -0
  28. package/dist/layout/core-app-library-dashboard.js +160 -0
  29. package/dist/layout/core-app-library-dashboard.js.map +1 -0
  30. package/dist/layout/index.d.ts +2 -0
  31. package/dist/layout/index.d.ts.map +1 -1
  32. package/dist/layout/index.js +38 -37
  33. package/dist/layout/index.js.map +1 -1
  34. package/dist/react/brand/index.d.ts +3 -1
  35. package/dist/react/brand/index.d.ts.map +1 -1
  36. package/dist/react/brand.js +19 -13
  37. package/dist/react/brand.js.map +1 -1
  38. package/dist/react/layout/core-app-library-dashboard.d.ts +13 -0
  39. package/dist/react/layout/core-app-library-dashboard.d.ts.map +1 -0
  40. package/dist/react/layout/core-app-library-dashboard.js +27 -0
  41. package/dist/react/layout/core-app-library-dashboard.js.map +1 -0
  42. package/dist/react/layout/index.d.ts +1 -0
  43. package/dist/react/layout/index.d.ts.map +1 -1
  44. package/dist/react/layout/index.js +3 -2
  45. package/dist/react/primitives/alert.d.ts +1 -1
  46. package/dist/react/primitives/toast.d.ts +1 -1
  47. package/dist/react/sidebar.js +1 -1
  48. package/dist/react.js +97 -96
  49. package/dist/sidebar/component.d.ts.map +1 -1
  50. package/dist/sidebar/index.js +1 -1
  51. package/dist/sidebar/types.d.ts +7 -0
  52. package/dist/sidebar/types.d.ts.map +1 -1
  53. package/dist/styles/components/brand.css +22 -0
  54. package/dist/styles/layout.css +577 -0
  55. package/dist/styles/sidebar.css +26 -0
  56. package/package.json +11 -1
  57. package/src/components/brand/index.ts +22 -4
  58. package/src/components/brand/types.ts +2 -0
  59. package/src/icons/icons.test.ts +1 -1
  60. package/src/icons/index.ts +7 -2
  61. package/src/icons/lucide.ts +4 -0
  62. package/src/index.ts +2 -0
  63. package/src/layout/app-shell.test.ts +76 -0
  64. package/src/layout/app-shell.ts +38 -7
  65. package/src/layout/core-app-chrome.test.ts +17 -5
  66. package/src/layout/core-app-chrome.ts +8 -3
  67. package/src/layout/core-app-library-dashboard.test.ts +397 -0
  68. package/src/layout/core-app-library-dashboard.ts +519 -0
  69. package/src/layout/index.ts +18 -0
  70. package/src/react/brand/index.test.tsx +10 -0
  71. package/src/react/brand/index.tsx +25 -4
  72. package/src/react/layout/core-app-chrome.test.tsx +2 -2
  73. package/src/react/layout/core-app-library-dashboard.test.tsx +42 -0
  74. package/src/react/layout/core-app-library-dashboard.tsx +56 -0
  75. package/src/react/layout/index.ts +6 -0
  76. package/src/sidebar/component.test.ts +21 -1
  77. package/src/sidebar/component.ts +14 -8
  78. package/src/sidebar/types.ts +7 -0
  79. package/src/styles/components/brand.css +22 -0
  80. package/src/styles/layout.css +577 -0
  81. package/src/styles/sidebar.css +26 -0
  82. package/dist/component-D5sRm1fq.js.map +0 -1
@@ -0,0 +1,519 @@
1
+ import { resolveContainer } from '../primitives/utils'
2
+
3
+ export type CoreAppLibraryMetricTone = 'cyan' | 'orange' | 'violet' | 'neutral'
4
+ export type CoreAppLibraryConnectorStatus = 'active' | 'error' | 'paused' | 'disabled'
5
+
6
+ export interface CoreAppLibraryMetric {
7
+ id: string
8
+ label: string
9
+ value: number | string
10
+ helper?: string
11
+ tone?: CoreAppLibraryMetricTone
12
+ }
13
+
14
+ export interface CoreAppLibraryTacticDepth {
15
+ label: string
16
+ value: number
17
+ }
18
+
19
+ export interface CoreAppLibraryFreshnessBucket {
20
+ label: string
21
+ detections: number
22
+ rules?: number
23
+ intel?: number
24
+ }
25
+
26
+ export interface CoreAppLibraryConnector {
27
+ id: string
28
+ name: string
29
+ status: CoreAppLibraryConnectorStatus
30
+ schedule: string
31
+ tags: string[]
32
+ lastRun: string
33
+ enabled?: boolean
34
+ }
35
+
36
+ export interface CoreAppLibraryPipelineSummary {
37
+ connectors: number
38
+ active: number
39
+ error: number
40
+ }
41
+
42
+ export interface CoreAppLibraryDashboardError {
43
+ source?: string
44
+ detail: string
45
+ }
46
+
47
+ export interface CoreAppLibraryDashboardData {
48
+ title?: string
49
+ subtitle?: string
50
+ metrics?: CoreAppLibraryMetric[]
51
+ tacticDepth?: CoreAppLibraryTacticDepth[]
52
+ freshness?: CoreAppLibraryFreshnessBucket[]
53
+ pipelineSummary?: CoreAppLibraryPipelineSummary
54
+ connectors?: CoreAppLibraryConnector[]
55
+ loading?: boolean
56
+ error?: string
57
+ errors?: CoreAppLibraryDashboardError[]
58
+ generatedAt?: string
59
+ }
60
+
61
+ export interface CoreAppLibraryDashboardMountOptions {
62
+ container: HTMLElement | string
63
+ data?: CoreAppLibraryDashboardData
64
+ className?: string
65
+ clearContainer?: boolean
66
+ }
67
+
68
+ export interface CoreAppLibraryDashboardDomMountOptions {
69
+ root?: ParentNode
70
+ selector?: string
71
+ dataSelector?: string
72
+ data?: CoreAppLibraryDashboardData
73
+ className?: string
74
+ clearContainer?: boolean
75
+ }
76
+
77
+ export interface CoreAppLibraryDashboardMount {
78
+ element: HTMLElement
79
+ update: (data: CoreAppLibraryDashboardData) => void
80
+ destroy: () => void
81
+ }
82
+
83
+ interface NormalizedDashboardData {
84
+ title: string
85
+ subtitle: string
86
+ metrics: CoreAppLibraryMetric[]
87
+ tacticDepth: CoreAppLibraryTacticDepth[]
88
+ freshness: CoreAppLibraryFreshnessBucket[]
89
+ pipelineSummary: CoreAppLibraryPipelineSummary
90
+ connectors: CoreAppLibraryConnector[]
91
+ loading: boolean
92
+ error?: string
93
+ errors: CoreAppLibraryDashboardError[]
94
+ generatedAt?: string
95
+ }
96
+
97
+ const DEFAULT_DOM_SELECTOR = '[data-core-app-library-dashboard]'
98
+ const DEFAULT_DATA_SELECTOR = '[data-core-app-library-dashboard-data]'
99
+ const DASHBOARD_TITLE_ID_PREFIX = 'cmm-library-dashboard-title'
100
+ let dashboardTitleIdSequence = 0
101
+
102
+ const EMPTY_PIPELINE_SUMMARY: CoreAppLibraryPipelineSummary = {
103
+ connectors: 0,
104
+ active: 0,
105
+ error: 0,
106
+ }
107
+
108
+ const createElement = <K extends keyof HTMLElementTagNameMap>(
109
+ tagName: K,
110
+ className?: string,
111
+ textContent?: string
112
+ ): HTMLElementTagNameMap[K] => {
113
+ const element = document.createElement(tagName)
114
+ if (className !== undefined) element.className = className
115
+ if (textContent !== undefined) element.textContent = textContent
116
+ return element
117
+ }
118
+
119
+ const toDisplayValue = (value: number | string): string =>
120
+ typeof value === 'number' ? value.toLocaleString() : value
121
+
122
+ const toNumber = (value: number | undefined): number =>
123
+ Number.isFinite(value) && value !== undefined ? value : 0
124
+
125
+ const createIntegerTicks = (maxValue: number, maxTicks = 5): number[] => {
126
+ const max = Math.max(0, Math.ceil(maxValue))
127
+ if (max === 0) return [0]
128
+ if (max <= maxTicks - 1) {
129
+ return Array.from({ length: max + 1 }, (_, index) => index)
130
+ }
131
+
132
+ const ticks = new Set<number>([0, max])
133
+ for (let index = 1; index < maxTicks - 1; index += 1) {
134
+ ticks.add(Math.round((max * index) / (maxTicks - 1)))
135
+ }
136
+
137
+ return Array.from(ticks).sort((left, right) => left - right)
138
+ }
139
+
140
+ const getDashboardTitleId = (root: HTMLElement): string => {
141
+ const existing = root.getAttribute('aria-labelledby')
142
+ if (existing !== null && existing.length > 0) return existing
143
+
144
+ dashboardTitleIdSequence += 1
145
+ return `${DASHBOARD_TITLE_ID_PREFIX}-${dashboardTitleIdSequence}`
146
+ }
147
+
148
+ const cloneMetrics = (metrics: CoreAppLibraryMetric[]): CoreAppLibraryMetric[] =>
149
+ metrics.map(metric => ({ ...metric }))
150
+
151
+ const cloneTactics = (tactics: CoreAppLibraryTacticDepth[]): CoreAppLibraryTacticDepth[] =>
152
+ tactics.map(tactic => ({ ...tactic }))
153
+
154
+ const cloneFreshness = (
155
+ buckets: CoreAppLibraryFreshnessBucket[]
156
+ ): CoreAppLibraryFreshnessBucket[] => buckets.map(bucket => ({ ...bucket }))
157
+
158
+ const cloneConnectors = (connectors: CoreAppLibraryConnector[]): CoreAppLibraryConnector[] =>
159
+ connectors.map(connector => ({ ...connector, tags: [...connector.tags] }))
160
+
161
+ const cloneErrors = (errors: CoreAppLibraryDashboardError[]): CoreAppLibraryDashboardError[] =>
162
+ errors.map(error => ({ ...error }))
163
+
164
+ const normalizeDashboardData = (
165
+ data: CoreAppLibraryDashboardData | undefined
166
+ ): NormalizedDashboardData => ({
167
+ title: data?.title ?? 'Library Dashboard',
168
+ subtitle: data?.subtitle ?? 'Detection content, MITRE coverage, and collection pipeline.',
169
+ metrics: cloneMetrics(data?.metrics ?? []),
170
+ tacticDepth: cloneTactics(data?.tacticDepth ?? []),
171
+ freshness: cloneFreshness(data?.freshness ?? []),
172
+ pipelineSummary: { ...(data?.pipelineSummary ?? EMPTY_PIPELINE_SUMMARY) },
173
+ connectors: cloneConnectors(data?.connectors ?? []),
174
+ loading: data?.loading ?? false,
175
+ ...(data?.error !== undefined ? { error: data.error } : {}),
176
+ errors: cloneErrors(data?.errors ?? []),
177
+ ...(data?.generatedAt !== undefined ? { generatedAt: data.generatedAt } : {}),
178
+ })
179
+
180
+ const parseDashboardData = (
181
+ element: HTMLElement | null
182
+ ): CoreAppLibraryDashboardData | undefined => {
183
+ if (element === null) return undefined
184
+
185
+ try {
186
+ const parsed: unknown = JSON.parse(element.textContent ?? '')
187
+ return parsed !== null && typeof parsed === 'object'
188
+ ? (parsed as CoreAppLibraryDashboardData)
189
+ : undefined
190
+ } catch {
191
+ return undefined
192
+ }
193
+ }
194
+
195
+ const appendMetricCard = (container: HTMLElement, metric: CoreAppLibraryMetric): void => {
196
+ const card = createElement('article', 'cmm-library-metric')
197
+ card.dataset.tone = metric.tone ?? 'neutral'
198
+ card.dataset.metricId = metric.id
199
+
200
+ card.append(
201
+ createElement('div', 'cmm-library-metric__label', metric.label),
202
+ createElement('div', 'cmm-library-metric__value', toDisplayValue(metric.value))
203
+ )
204
+
205
+ if (metric.helper !== undefined) {
206
+ card.appendChild(createElement('div', 'cmm-library-metric__helper', metric.helper))
207
+ }
208
+
209
+ container.appendChild(card)
210
+ }
211
+
212
+ const appendEmpty = (
213
+ container: HTMLElement,
214
+ text: string,
215
+ className = 'cmm-library-dashboard__empty'
216
+ ): void => {
217
+ container.appendChild(createElement('div', className, text))
218
+ }
219
+
220
+ const appendMetricStrip = (container: HTMLElement, metrics: CoreAppLibraryMetric[]): void => {
221
+ const strip = createElement('div', 'cmm-library-dashboard__metrics')
222
+ if (metrics.length === 0) {
223
+ appendEmpty(strip, 'No metrics available')
224
+ container.appendChild(strip)
225
+ return
226
+ }
227
+
228
+ for (const metric of metrics) {
229
+ appendMetricCard(strip, metric)
230
+ }
231
+ container.appendChild(strip)
232
+ }
233
+
234
+ const appendDashboardMeta = (
235
+ container: HTMLElement,
236
+ data: Pick<NormalizedDashboardData, 'errors' | 'generatedAt'>
237
+ ): void => {
238
+ if (data.errors.length === 0 && data.generatedAt === undefined) return
239
+
240
+ const meta = createElement('div', 'cmm-library-dashboard__meta')
241
+ if (data.generatedAt !== undefined) {
242
+ meta.appendChild(
243
+ createElement('span', 'cmm-library-dashboard__timestamp', `Updated ${data.generatedAt}`)
244
+ )
245
+ }
246
+
247
+ for (const error of data.errors) {
248
+ const detail = error.source !== undefined ? `${error.source}: ${error.detail}` : error.detail
249
+ meta.appendChild(createElement('span', 'cmm-library-dashboard__warning', detail))
250
+ }
251
+
252
+ container.appendChild(meta)
253
+ }
254
+
255
+ const appendTacticDepthChart = (
256
+ container: HTMLElement,
257
+ tactics: CoreAppLibraryTacticDepth[]
258
+ ): void => {
259
+ const panel = createElement('section', 'cmm-library-chart cmm-library-chart--depth')
260
+ const title = createElement('h2', 'cmm-library-chart__title', 'Detection Depth by Tactic')
261
+ const body = createElement('div', 'cmm-library-depth')
262
+
263
+ if (tactics.length === 0) {
264
+ appendEmpty(body, 'No tactic coverage yet', 'cmm-library-chart__empty')
265
+ panel.append(title, body)
266
+ container.appendChild(panel)
267
+ return
268
+ }
269
+
270
+ const maxValue = Math.max(1, ...tactics.map(tactic => tactic.value))
271
+
272
+ for (const tactic of tactics) {
273
+ const row = createElement('div', 'cmm-library-depth__row')
274
+ const label = createElement('div', 'cmm-library-depth__label', tactic.label)
275
+ const barTrack = createElement('div', 'cmm-library-depth__track')
276
+ const bar = createElement('span', 'cmm-library-depth__bar')
277
+ bar.style.width = `${Math.max(4, (tactic.value / maxValue) * 100).toFixed(2)}%`
278
+ bar.setAttribute('aria-label', `${tactic.label}: ${tactic.value}`)
279
+ barTrack.appendChild(bar)
280
+ row.append(label, barTrack)
281
+ body.appendChild(row)
282
+ }
283
+
284
+ const axis = createElement('div', 'cmm-library-depth__axis')
285
+ for (const label of createIntegerTicks(maxValue)) {
286
+ axis.appendChild(createElement('span', undefined, String(label)))
287
+ }
288
+ body.appendChild(axis)
289
+ panel.append(title, body)
290
+ container.appendChild(panel)
291
+ }
292
+
293
+ const appendFreshnessChart = (
294
+ container: HTMLElement,
295
+ buckets: CoreAppLibraryFreshnessBucket[]
296
+ ): void => {
297
+ const panel = createElement('section', 'cmm-library-chart cmm-library-chart--freshness')
298
+ const title = createElement('h2', 'cmm-library-chart__title', 'Content Freshness (30d)')
299
+ const body = createElement('div', 'cmm-library-freshness')
300
+
301
+ if (buckets.length === 0) {
302
+ appendEmpty(body, 'No recent content activity', 'cmm-library-chart__empty')
303
+ panel.append(title, body)
304
+ container.appendChild(panel)
305
+ return
306
+ }
307
+
308
+ const plot = createElement('div', 'cmm-library-freshness__plot')
309
+ const maxTotal = Math.max(
310
+ 1,
311
+ ...buckets.map(bucket => bucket.detections + toNumber(bucket.rules) + toNumber(bucket.intel))
312
+ )
313
+
314
+ for (const bucket of buckets) {
315
+ const column = createElement('div', 'cmm-library-freshness__bucket')
316
+ const stack = createElement('div', 'cmm-library-freshness__stack')
317
+ const total = bucket.detections + toNumber(bucket.rules) + toNumber(bucket.intel)
318
+ stack.style.height = `${Math.max(8, (total / maxTotal) * 100).toFixed(2)}%`
319
+ stack.setAttribute('aria-label', `${bucket.label || 'Freshness bucket'}: ${total}`)
320
+
321
+ const detections = createElement('span', 'cmm-library-freshness__segment')
322
+ detections.dataset.tone = 'cyan'
323
+ detections.style.flexGrow = String(bucket.detections)
324
+ const rules = createElement('span', 'cmm-library-freshness__segment')
325
+ rules.dataset.tone = 'green'
326
+ rules.style.flexGrow = String(toNumber(bucket.rules))
327
+ const intel = createElement('span', 'cmm-library-freshness__segment')
328
+ intel.dataset.tone = 'orange'
329
+ intel.style.flexGrow = String(toNumber(bucket.intel))
330
+
331
+ stack.append(detections, rules, intel)
332
+ column.appendChild(stack)
333
+ if (bucket.label.length > 0) {
334
+ column.appendChild(createElement('span', 'cmm-library-freshness__label', bucket.label))
335
+ }
336
+ plot.appendChild(column)
337
+ }
338
+
339
+ const axis = createElement('div', 'cmm-library-freshness__axis')
340
+ const axisMax = Math.max(
341
+ 1,
342
+ ...buckets.map(bucket => bucket.detections + toNumber(bucket.rules) + toNumber(bucket.intel))
343
+ )
344
+ for (const label of createIntegerTicks(axisMax).reverse()) {
345
+ axis.appendChild(createElement('span', undefined, String(label)))
346
+ }
347
+
348
+ body.append(axis, plot)
349
+ panel.append(title, body)
350
+ container.appendChild(panel)
351
+ }
352
+
353
+ const appendCharts = (
354
+ container: HTMLElement,
355
+ data: Pick<NormalizedDashboardData, 'tacticDepth' | 'freshness'>
356
+ ): void => {
357
+ const charts = createElement('div', 'cmm-library-dashboard__charts')
358
+ appendTacticDepthChart(charts, data.tacticDepth)
359
+ appendFreshnessChart(charts, data.freshness)
360
+ container.appendChild(charts)
361
+ }
362
+
363
+ const connectorTone = (status: CoreAppLibraryConnectorStatus): string => {
364
+ if (status === 'active') return 'success'
365
+ if (status === 'error') return 'error'
366
+ if (status === 'paused') return 'warn'
367
+ return 'neutral'
368
+ }
369
+
370
+ const appendConnectorCard = (container: HTMLElement, connector: CoreAppLibraryConnector): void => {
371
+ const card = createElement('article', 'cmm-library-pipeline-card')
372
+ card.dataset.status = connector.status
373
+
374
+ const head = createElement('div', 'cmm-library-pipeline-card__head')
375
+ const nameWrap = createElement('div', 'cmm-library-pipeline-card__name-wrap')
376
+ const glyph = createElement('span', 'cmm-library-pipeline-card__glyph', '')
377
+ glyph.setAttribute('aria-hidden', 'true')
378
+ nameWrap.append(glyph, createElement('h3', 'cmm-library-pipeline-card__name', connector.name))
379
+
380
+ const badge = createElement(
381
+ 'span',
382
+ 'cmm-library-pipeline-card__badge',
383
+ connector.status[0]?.toUpperCase() + connector.status.slice(1)
384
+ )
385
+ badge.dataset.tone = connectorTone(connector.status)
386
+ head.append(nameWrap, badge)
387
+
388
+ const meta = createElement('div', 'cmm-library-pipeline-card__meta')
389
+ meta.appendChild(createElement('span', undefined, connector.schedule))
390
+ for (const tag of connector.tags) {
391
+ meta.appendChild(createElement('span', undefined, tag))
392
+ }
393
+
394
+ const foot = createElement('div', 'cmm-library-pipeline-card__foot')
395
+ foot.append(
396
+ createElement('span', undefined, connector.lastRun),
397
+ createElement('span', undefined, connector.enabled === false ? 'Disabled' : 'Enabled')
398
+ )
399
+
400
+ card.append(head, meta, foot)
401
+ container.appendChild(card)
402
+ }
403
+
404
+ const appendPipeline = (
405
+ container: HTMLElement,
406
+ data: Pick<NormalizedDashboardData, 'pipelineSummary' | 'connectors'>
407
+ ): void => {
408
+ const section = createElement('section', 'cmm-library-dashboard__pipeline')
409
+ const heading = createElement('div', 'cmm-library-dashboard__pipeline-heading')
410
+ heading.appendChild(createElement('h2', 'cmm-library-dashboard__eyebrow', 'Collection Pipeline'))
411
+
412
+ const summary = createElement('div', 'cmm-library-dashboard__pipeline-summary')
413
+ summary.append(
414
+ createElement('span', undefined, `${data.pipelineSummary.connectors} connectors`),
415
+ createElement('span', undefined, `${data.pipelineSummary.active} active`),
416
+ createElement('span', undefined, `${data.pipelineSummary.error} error`)
417
+ )
418
+ heading.appendChild(summary)
419
+
420
+ const grid = createElement('div', 'cmm-library-dashboard__pipeline-grid')
421
+ if (data.connectors.length === 0) {
422
+ appendEmpty(grid, 'No connectors configured')
423
+ } else {
424
+ for (const connector of data.connectors) {
425
+ appendConnectorCard(grid, connector)
426
+ }
427
+ }
428
+
429
+ section.append(heading, grid)
430
+ container.appendChild(section)
431
+ }
432
+
433
+ const appendDashboardState = (container: HTMLElement, title: string, detail?: string): void => {
434
+ const state = createElement('div', 'cmm-library-dashboard__state')
435
+ state.appendChild(createElement('h2', 'cmm-library-dashboard__state-title', title))
436
+ if (detail !== undefined && detail.length > 0) {
437
+ state.appendChild(createElement('p', 'cmm-library-dashboard__state-detail', detail))
438
+ }
439
+ container.appendChild(state)
440
+ }
441
+
442
+ const renderDashboard = (root: HTMLElement, data: NormalizedDashboardData): void => {
443
+ const titleId = getDashboardTitleId(root)
444
+ root.setAttribute('aria-labelledby', titleId)
445
+
446
+ const inner = createElement('div', 'cmm-library-dashboard__inner')
447
+ const header = createElement('header', 'cmm-library-dashboard__header')
448
+ const title = createElement('h1', 'cmm-library-dashboard__title', data.title)
449
+ title.id = titleId
450
+ header.append(title, createElement('p', 'cmm-library-dashboard__subtitle', data.subtitle))
451
+
452
+ inner.appendChild(header)
453
+ appendDashboardMeta(inner, data)
454
+ if (data.loading) {
455
+ appendDashboardState(inner, 'Loading library dashboard')
456
+ root.replaceChildren(inner)
457
+ return
458
+ }
459
+ if (data.error !== undefined) {
460
+ appendDashboardState(inner, 'Unable to load dashboard', data.error)
461
+ root.replaceChildren(inner)
462
+ return
463
+ }
464
+
465
+ appendMetricStrip(inner, data.metrics)
466
+ appendCharts(inner, data)
467
+ appendPipeline(inner, data)
468
+ root.replaceChildren(inner)
469
+ }
470
+
471
+ export function mountCoreAppLibraryDashboard(
472
+ options: CoreAppLibraryDashboardMountOptions
473
+ ): CoreAppLibraryDashboardMount {
474
+ const container = resolveContainer(options.container)
475
+ const element = createElement('section', 'cmm-library-dashboard')
476
+
477
+ if (options.className !== undefined && options.className.length > 0) {
478
+ element.classList.add(...options.className.split(/\s+/).filter(Boolean))
479
+ }
480
+
481
+ renderDashboard(element, normalizeDashboardData(options.data))
482
+
483
+ if (options.clearContainer !== false) {
484
+ container.replaceChildren(element)
485
+ } else {
486
+ container.appendChild(element)
487
+ }
488
+
489
+ return {
490
+ element,
491
+ update: (data: CoreAppLibraryDashboardData) => {
492
+ renderDashboard(element, normalizeDashboardData(data))
493
+ },
494
+ destroy: () => {
495
+ element.remove()
496
+ },
497
+ }
498
+ }
499
+
500
+ export function mountCoreAppLibraryDashboardFromDom(
501
+ options: CoreAppLibraryDashboardDomMountOptions = {}
502
+ ): CoreAppLibraryDashboardMount | null {
503
+ const root = options.root ?? document
504
+ const container = root.querySelector<HTMLElement>(options.selector ?? DEFAULT_DOM_SELECTOR)
505
+ if (container === null) {
506
+ return null
507
+ }
508
+
509
+ const dataElement = root.querySelector<HTMLElement>(options.dataSelector ?? DEFAULT_DATA_SELECTOR)
510
+ const mountOptions: CoreAppLibraryDashboardMountOptions = { container }
511
+ const data = options.data ?? parseDashboardData(dataElement)
512
+ if (data !== undefined) mountOptions.data = data
513
+ if (options.className !== undefined) mountOptions.className = options.className
514
+ if (options.clearContainer !== undefined) {
515
+ mountOptions.clearContainer = options.clearContainer
516
+ }
517
+
518
+ return mountCoreAppLibraryDashboard(mountOptions)
519
+ }
@@ -43,6 +43,24 @@ export type {
43
43
  CoreAppSidebarPresetOptions,
44
44
  CoreAppTopbarPresetOptions,
45
45
  } from './core-app-chrome'
46
+ export {
47
+ mountCoreAppLibraryDashboard,
48
+ mountCoreAppLibraryDashboardFromDom,
49
+ } from './core-app-library-dashboard'
50
+ export type {
51
+ CoreAppLibraryConnector,
52
+ CoreAppLibraryConnectorStatus,
53
+ CoreAppLibraryDashboardData,
54
+ CoreAppLibraryDashboardDomMountOptions,
55
+ CoreAppLibraryDashboardError,
56
+ CoreAppLibraryDashboardMount,
57
+ CoreAppLibraryDashboardMountOptions,
58
+ CoreAppLibraryFreshnessBucket,
59
+ CoreAppLibraryMetric,
60
+ CoreAppLibraryMetricTone,
61
+ CoreAppLibraryPipelineSummary,
62
+ CoreAppLibraryTacticDepth,
63
+ } from './core-app-library-dashboard'
46
64
  export { PageHeader, createPageHeader } from './page-header'
47
65
  export type { PageHeaderConfig, PageHeaderMetadata } from './page-header'
48
66
 
@@ -25,4 +25,14 @@ describe('React brand lockup', () => {
25
25
  expect(lockup?.querySelector('.cmm-diamond')).toBeTruthy()
26
26
  expect(lockup?.querySelector('.cmm-brand-lockup__wordmark')).toBeNull()
27
27
  })
28
+
29
+ it('supports the Threat Library title-case brand treatment', () => {
30
+ const { container } = render(<BrandLockup subtitle="Platform" wordmarkCase="title" />)
31
+ const lockup = container.querySelector('.cmm-brand-lockup')
32
+
33
+ expect(lockup?.classList.contains('cmm-brand-lockup--title')).toBe(true)
34
+ expect(lockup?.querySelector('.cmm-brand-lockup__counter')?.textContent).toBe('Counter')
35
+ expect(lockup?.querySelector('.cmm-brand-lockup__measure')?.textContent).toBe('Measure')
36
+ expect(lockup?.querySelector('.cmm-brand-lockup__subtitle')?.textContent).toBe('Platform')
37
+ })
28
38
  })
@@ -163,6 +163,8 @@ export function Wordmark({
163
163
  export interface BrandLockupProps extends React.HTMLAttributes<HTMLSpanElement> {
164
164
  markSize?: BrandSize | number
165
165
  label?: string
166
+ subtitle?: string
167
+ wordmarkCase?: BrandLockupConfig['wordmarkCase']
166
168
  decorative?: boolean
167
169
  showWordmark?: boolean
168
170
  }
@@ -171,6 +173,8 @@ export function BrandLockup({
171
173
  className,
172
174
  markSize = 22,
173
175
  label = 'CounterMeasure',
176
+ subtitle,
177
+ wordmarkCase = 'uppercase',
174
178
  decorative = false,
175
179
  showWordmark = true,
176
180
  ...props
@@ -180,12 +184,29 @@ export function BrandLockup({
180
184
  : { role: 'img', 'aria-label': label }
181
185
 
182
186
  return (
183
- <span className={cn('cmm-brand-lockup', className)} {...accessibilityProps} {...props}>
187
+ <span
188
+ className={cn(
189
+ 'cmm-brand-lockup',
190
+ wordmarkCase === 'title' && 'cmm-brand-lockup--title',
191
+ className
192
+ )}
193
+ {...accessibilityProps}
194
+ {...props}
195
+ >
184
196
  <Diamond size={markSize} decorative />
185
197
  {showWordmark ? (
186
- <span className="cmm-brand-lockup__wordmark" aria-hidden="true">
187
- <span className="cmm-brand-lockup__counter">COUNTER</span>
188
- <span className="cmm-brand-lockup__measure">MEASURE</span>
198
+ <span className="cmm-brand-lockup__copy" aria-hidden="true">
199
+ <span className="cmm-brand-lockup__wordmark">
200
+ <span className="cmm-brand-lockup__counter">
201
+ {wordmarkCase === 'title' ? 'Counter' : 'COUNTER'}
202
+ </span>
203
+ <span className="cmm-brand-lockup__measure">
204
+ {wordmarkCase === 'title' ? 'Measure' : 'MEASURE'}
205
+ </span>
206
+ </span>
207
+ {subtitle !== undefined ? (
208
+ <span className="cmm-brand-lockup__subtitle">{subtitle}</span>
209
+ ) : null}
189
210
  </span>
190
211
  ) : null}
191
212
  </span>
@@ -28,9 +28,9 @@ describe('CoreAppChrome React wrapper', () => {
28
28
 
29
29
  expect(container.querySelector('[data-slot="core-app-chrome"]')).toBeTruthy()
30
30
  expect(sidebar?.classList.contains('sidebar--product')).toBe(true)
31
- expect(sidebar?.getAttribute('data-sidebar')).toBe('cm')
31
+ expect(sidebar?.getAttribute('data-sidebar')).toBe('threat-library')
32
32
  expect(active?.classList.contains('sidebar__item--active')).toBe(true)
33
- expect(topbar?.querySelector('.cmm-topbar__product')?.textContent).toBe('CounterMeasure Core')
33
+ expect(topbar?.querySelector('.cmm-topbar__product')?.textContent).toBe('Threat Library')
34
34
  expect(topbar?.querySelector('.cmm-topbar__current')?.textContent).toBe('Jobs')
35
35
  expect(container.querySelector('.sidebar__scope-current')?.textContent).toBe('All Tenants')
36
36
  })
@@ -0,0 +1,42 @@
1
+ import { render } from '@testing-library/react'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { CoreAppLibraryDashboard } from './core-app-library-dashboard'
5
+
6
+ describe('CoreAppLibraryDashboard React wrapper', () => {
7
+ it('mounts the vanilla dashboard inside a React host', () => {
8
+ const { container } = render(
9
+ <CoreAppLibraryDashboard
10
+ data={{
11
+ title: 'React Library',
12
+ metrics: [{ id: 'total', label: 'Total', value: '12' }],
13
+ }}
14
+ />
15
+ )
16
+
17
+ expect(container.querySelector('[data-slot="core-app-library-dashboard"]')).toBeTruthy()
18
+ expect(container.querySelector('.cmm-library-dashboard__title')?.textContent).toBe(
19
+ 'React Library'
20
+ )
21
+ expect(container.querySelector('.cmm-library-metric__value')?.textContent).toBe('12')
22
+ })
23
+
24
+ it('updates the existing vanilla mount when data changes', () => {
25
+ const { container, rerender } = render(
26
+ <CoreAppLibraryDashboard data={{ title: 'First Library' }} />
27
+ )
28
+ const dashboard = container.querySelector('.cmm-library-dashboard')
29
+
30
+ expect(container.querySelector('.cmm-library-dashboard__title')?.textContent).toBe(
31
+ 'First Library'
32
+ )
33
+
34
+ rerender(<CoreAppLibraryDashboard data={{ title: 'Second Library' }} />)
35
+
36
+ expect(container.querySelector('.cmm-library-dashboard__title')?.textContent).toBe(
37
+ 'Second Library'
38
+ )
39
+ expect(container.querySelector('.cmm-library-dashboard')).toBe(dashboard)
40
+ expect(container.querySelectorAll('.cmm-library-dashboard')).toHaveLength(1)
41
+ })
42
+ })