@morscherlab/mint-sdk 1.0.0 → 1.0.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 (68) hide show
  1. package/dist/BaseModal-B9UA8Y_I.js +165 -0
  2. package/dist/BaseModal-B9UA8Y_I.js.map +1 -0
  3. package/dist/BaseSelect-DksaKYq_.js +176 -0
  4. package/dist/BaseSelect-DksaKYq_.js.map +1 -0
  5. package/dist/ExperimentPopover-CCYB1oWp.js +361 -0
  6. package/dist/ExperimentPopover-CCYB1oWp.js.map +1 -0
  7. package/dist/ExperimentPopover-D0bg_fqM.js +3 -0
  8. package/dist/ExperimentSelectorModal-B_kPbXcg.js +4 -0
  9. package/dist/ExperimentSelectorModal-wm7yUdAr.js +720 -0
  10. package/dist/ExperimentSelectorModal-wm7yUdAr.js.map +1 -0
  11. package/dist/SettingsModal-L7Ejny45.js +5 -0
  12. package/dist/SettingsModal-LEKI6Ebl.js +521 -0
  13. package/dist/SettingsModal-LEKI6Ebl.js.map +1 -0
  14. package/dist/{auth-BulIv_km.js → auth-D9q2GIcv.js} +3 -80
  15. package/dist/auth-D9q2GIcv.js.map +1 -0
  16. package/dist/components/DataFrame.vue.d.ts +3 -0
  17. package/dist/components/ExperimentDataViewer.vue.d.ts +2 -0
  18. package/dist/components/PluginWorkspaceView.vue.d.ts +2 -2
  19. package/dist/components/index.js +7 -2
  20. package/dist/{components-DtX3LDLq.js → components-CdjRzHI2.js} +533 -2025
  21. package/dist/components-CdjRzHI2.js.map +1 -0
  22. package/dist/composables/index.js +9 -3
  23. package/dist/composables/usePluginClient.d.ts +2 -1
  24. package/dist/{composables-wNt7VtkF.js → composables-DJgqPrlR.js} +7 -12
  25. package/dist/{composables-wNt7VtkF.js.map → composables-DJgqPrlR.js.map} +1 -1
  26. package/dist/experiment-utils-hGXMHlAc.js +109 -0
  27. package/dist/experiment-utils-hGXMHlAc.js.map +1 -0
  28. package/dist/index.js +16 -5
  29. package/dist/index.js.map +1 -1
  30. package/dist/install.js +7 -2
  31. package/dist/install.js.map +1 -1
  32. package/dist/permissions.js +81 -0
  33. package/dist/permissions.js.map +1 -0
  34. package/dist/stores/index.js +1 -1
  35. package/dist/styles.css +3233 -3185
  36. package/dist/templates/index.js +3 -1
  37. package/dist/templates-Do43ZIMb.js +5065 -0
  38. package/dist/templates-Do43ZIMb.js.map +1 -0
  39. package/dist/{templates-DSbHJC4v.js → useControlSchema-0n8Bcftq.js} +10 -5335
  40. package/dist/useControlSchema-0n8Bcftq.js.map +1 -0
  41. package/dist/useDropdownState-Ben4DnjJ.js +47 -0
  42. package/dist/useDropdownState-Ben4DnjJ.js.map +1 -0
  43. package/dist/useEventListener-CfVkP9Xz.js +57 -0
  44. package/dist/useEventListener-CfVkP9Xz.js.map +1 -0
  45. package/dist/useExperimentSelector-BpZklTbV.js +469 -0
  46. package/dist/useExperimentSelector-BpZklTbV.js.map +1 -0
  47. package/dist/useFormBuilder-COfYWDuC.js +729 -0
  48. package/dist/useFormBuilder-COfYWDuC.js.map +1 -0
  49. package/dist/{useProtocolTemplates-DwBhEPPU.js → useProtocolTemplates-TUQO_F3n.js} +8 -1298
  50. package/dist/useProtocolTemplates-TUQO_F3n.js.map +1 -0
  51. package/dist/utils/pluginIcon.d.ts +29 -2
  52. package/package.json +5 -1
  53. package/src/__tests__/components/DataFrame.test.ts +37 -0
  54. package/src/__tests__/components/PluginIcon.test.ts +77 -0
  55. package/src/__tests__/composables/usePluginClient.test.ts +11 -10
  56. package/src/components/AppTopBar.vue +7 -6
  57. package/src/components/DataFrame.vue +27 -2
  58. package/src/components/ExperimentDataViewer.vue +5 -1
  59. package/src/components/PluginIcon.story.vue +31 -1
  60. package/src/components/PluginIcon.vue +94 -4
  61. package/src/composables/usePluginClient.ts +3 -12
  62. package/src/styles/components/dataframe.css +26 -0
  63. package/src/styles/components/plugin-icon.css +5 -0
  64. package/src/utils/pluginIcon.ts +159 -2
  65. package/dist/auth-BulIv_km.js.map +0 -1
  66. package/dist/components-DtX3LDLq.js.map +0 -1
  67. package/dist/templates-DSbHJC4v.js.map +0 -1
  68. package/dist/useProtocolTemplates-DwBhEPPU.js.map +0 -1
@@ -1,7 +1,34 @@
1
- export type PluginIconFormat = 'path' | 'data-url' | 'https-url' | 'fallback';
1
+ export type PluginIconFormat = 'path' | 'data-url' | 'https-url' | 'structured' | 'fallback';
2
+ export interface StructuredPluginIconStop {
3
+ offset: string;
4
+ color: string;
5
+ opacity?: number;
6
+ }
7
+ export interface StructuredPluginIconBackground {
8
+ fill: string;
9
+ radius: number;
10
+ }
11
+ export interface StructuredPluginIconPath {
12
+ d: string;
13
+ fill?: string;
14
+ stroke?: string;
15
+ strokeWidth?: number;
16
+ opacity?: number;
17
+ }
18
+ export interface StructuredPluginIcon {
19
+ type: 'mint-icon';
20
+ viewBox: string;
21
+ background?: StructuredPluginIconBackground;
22
+ gradient?: {
23
+ type: 'linear';
24
+ angle: number;
25
+ stops: StructuredPluginIconStop[];
26
+ };
27
+ paths: StructuredPluginIconPath[];
28
+ }
2
29
  export interface DetectedPluginIcon {
3
30
  format: PluginIconFormat;
4
- value: string;
31
+ value: string | StructuredPluginIcon;
5
32
  }
6
33
  export declare const PLUGIN_ICON_FALLBACK_PATH = "M14 7v4a1 1 0 0 0 1 1h4M5 3h9l5 5v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z";
7
34
  export declare function normalizePluginIconSource(icon?: string): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morscherlab/mint-sdk",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "MINT Platform SDK — Vue 3 components, composables, and types for plugin development. MINT = Mass-spec INtegrated Toolkit.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -31,6 +31,10 @@
31
31
  "types": "./dist/stores/index.d.ts",
32
32
  "import": "./dist/stores/index.js"
33
33
  },
34
+ "./permissions": {
35
+ "types": "./dist/permissions.d.ts",
36
+ "import": "./dist/permissions.js"
37
+ },
34
38
  "./templates": {
35
39
  "types": "./dist/templates/index.d.ts",
36
40
  "import": "./dist/templates/index.js"
@@ -509,6 +509,43 @@ describe('DataFrame', () => {
509
509
  expect(column.key).toBe('name')
510
510
  expect(row.name).toBe('Alice')
511
511
  })
512
+
513
+ it('should emit row-click from cell clicks when clickableRows is true', async () => {
514
+ const wrapper = mount(DataFrame, {
515
+ props: { data: mockData, columns: mockColumns, clickableRows: true },
516
+ })
517
+ const firstCell = wrapper.find('.mint-dataframe__td')
518
+ await firstCell.trigger('click')
519
+
520
+ expect(wrapper.emitted('cell-click')).toHaveLength(1)
521
+ expect(wrapper.emitted('row-click')).toHaveLength(1)
522
+ const [row, index] = wrapper.emitted('row-click')?.[0] as [Record<string, unknown>, number]
523
+ expect(row.name).toBe('Alice')
524
+ expect(index).toBe(0)
525
+ })
526
+
527
+ it('should make clickable rows keyboard accessible', async () => {
528
+ const wrapper = mount(DataFrame, {
529
+ props: { data: mockData, columns: mockColumns, clickableRows: true },
530
+ })
531
+ const firstRow = wrapper.find('.mint-dataframe__row')
532
+ expect(firstRow.attributes('tabindex')).toBe('0')
533
+ expect(firstRow.attributes('role')).toBe('button')
534
+
535
+ await firstRow.trigger('keydown', { key: 'Enter' })
536
+ await firstRow.trigger('keydown', { key: ' ' })
537
+
538
+ expect(wrapper.emitted('row-click')).toHaveLength(2)
539
+ })
540
+
541
+ it('should keep non-clickable rows out of the tab order', () => {
542
+ const wrapper = mount(DataFrame, {
543
+ props: { data: mockData, columns: mockColumns },
544
+ })
545
+ const firstRow = wrapper.find('.mint-dataframe__row')
546
+ expect(firstRow.attributes('tabindex')).toBeUndefined()
547
+ expect(firstRow.attributes('role')).toBeUndefined()
548
+ })
512
549
  })
513
550
 
514
551
  describe('empty state', () => {
@@ -1,9 +1,30 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import { mount } from '@vue/test-utils'
3
3
  import PluginIcon from '../../components/PluginIcon.vue'
4
+ import { PLUGIN_ICON_FALLBACK_PATH } from '../../utils/pluginIcon'
4
5
 
5
6
  const SAMPLE_PATH = 'M13 10V3L4 14h7v7l9-11h-7z'
6
7
  const SAMPLE_HTTPS_URL = 'https://example.com/icon.png'
8
+ const SAMPLE_STRUCTURED_ICON = JSON.stringify({
9
+ type: 'mint-icon',
10
+ viewBox: '0 0 24 24',
11
+ gradient: {
12
+ type: 'linear',
13
+ angle: 135,
14
+ stops: [
15
+ { offset: '0%', color: '#AAECBF' },
16
+ { offset: '100%', color: '#14A1A9' },
17
+ ],
18
+ },
19
+ background: { fill: 'gradient', radius: 5 },
20
+ paths: [
21
+ {
22
+ d: 'M7 17c5.8-.2 9.5-3.8 10-10-5.7.5-9.3 4.2-10 10z',
23
+ fill: 'white',
24
+ opacity: 0.95,
25
+ },
26
+ ],
27
+ })
7
28
 
8
29
  describe('PluginIcon', () => {
9
30
  describe('format detection', () => {
@@ -15,6 +36,62 @@ describe('PluginIcon', () => {
15
36
  expect(wrapper.find('img').exists()).toBe(false)
16
37
  })
17
38
 
39
+ it('should render structured gradient vector icons without an image tag', () => {
40
+ const wrapper = mount(PluginIcon, { props: { icon: SAMPLE_STRUCTURED_ICON } })
41
+ const svg = wrapper.find('svg.mint-plugin-icon__svg--structured')
42
+ const rect = wrapper.find('rect')
43
+ const stops = wrapper.findAll('stop')
44
+ const path = wrapper.find('path')
45
+
46
+ expect(svg.exists()).toBe(true)
47
+ expect(svg.attributes('viewBox')).toBe('0 0 24 24')
48
+ expect(rect.attributes('fill')).toMatch(/^url\(#mint-plugin-icon-gradient-/)
49
+ expect(rect.attributes('rx')).toBe('5')
50
+ expect(stops).toHaveLength(2)
51
+ expect(stops[0].attributes('stop-color')).toBe('#AAECBF')
52
+ expect(stops[1].attributes('stop-color')).toBe('#14A1A9')
53
+ expect(path.attributes('fill')).toBe('white')
54
+ expect(wrapper.find('img').exists()).toBe(false)
55
+ })
56
+
57
+ it('should reject structured icons without paths as fallback', () => {
58
+ const icon = JSON.stringify({
59
+ type: 'mint-icon',
60
+ background: { fill: '#14A1A9', radius: 5 },
61
+ paths: [],
62
+ })
63
+ const wrapper = mount(PluginIcon, { props: { icon } })
64
+ expect(wrapper.find('svg path').attributes('d')).toBe(PLUGIN_ICON_FALLBACK_PATH)
65
+ expect(wrapper.find('.mint-plugin-icon__svg--structured').exists()).toBe(false)
66
+ })
67
+
68
+ it('should accept structured gradient shorthand with from and to colors', () => {
69
+ const icon = JSON.stringify({
70
+ type: 'mint-icon',
71
+ gradient: { type: 'linear', from: '#AAECBF', to: '#14A1A9' },
72
+ background: { fill: 'gradient' },
73
+ paths: [{ d: SAMPLE_PATH, fill: 'white' }],
74
+ })
75
+ const wrapper = mount(PluginIcon, { props: { icon } })
76
+ const stops = wrapper.findAll('stop')
77
+ expect(stops).toHaveLength(2)
78
+ expect(stops[0].attributes('stop-color')).toBe('#AAECBF')
79
+ expect(stops[1].attributes('stop-color')).toBe('#14A1A9')
80
+ })
81
+
82
+ it('should ignore unsafe structured icon colors and still render safe paths', () => {
83
+ const icon = JSON.stringify({
84
+ type: 'mint-icon',
85
+ background: { fill: 'url(javascript:alert(1))', radius: 5 },
86
+ paths: [{ d: SAMPLE_PATH, fill: 'javascript:alert(1)', stroke: '#14A1A9' }],
87
+ })
88
+ const wrapper = mount(PluginIcon, { props: { icon } })
89
+ const path = wrapper.find('path')
90
+ expect(wrapper.find('rect').exists()).toBe(false)
91
+ expect(path.attributes('fill')).toBe('none')
92
+ expect(path.attributes('stroke')).toBe('#14A1A9')
93
+ })
94
+
18
95
  it('should render SVG path when icon starts with lowercase m and a space', () => {
19
96
  const wrapper = mount(PluginIcon, { props: { icon: 'm 5,5 l 10,10' } })
20
97
  const path = wrapper.find('svg path')
@@ -356,7 +356,7 @@ describe('usePluginClient', () => {
356
356
  expect(requestConfigs).toHaveLength(0)
357
357
  })
358
358
 
359
- it('provides a dashboard page selector fallback when contract nav items are absent', () => {
359
+ it('does not synthesize page selector items when contract nav items are absent', () => {
360
360
  expect(getPluginPageSelectorItems({
361
361
  ...contract,
362
362
  plugin: {
@@ -366,15 +366,16 @@ describe('usePluginClient', () => {
366
366
  icon: 'PLUGIN_ICON',
367
367
  navItems: [],
368
368
  },
369
- })).toEqual([
370
- {
371
- id: 'dashboard',
372
- label: 'Dose Designer',
373
- to: '/',
374
- icon: 'PLUGIN_ICON',
375
- hint: 'Build dose designs',
376
- },
377
- ])
369
+ })).toEqual([])
370
+ expect(requestConfigs).toHaveLength(0)
371
+ })
372
+
373
+ it('does not synthesize page selector items when contract nav items are omitted', () => {
374
+ const { navItems: _navItems, ...pluginWithoutNavItems } = contract.plugin
375
+ expect(getPluginPageSelectorItems({
376
+ ...contract,
377
+ plugin: pluginWithoutNavItems,
378
+ })).toEqual([])
378
379
  expect(requestConfigs).toHaveLength(0)
379
380
  })
380
381
 
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  /** Full application top bar with brand logo, page selector or plugin switcher, centered pill nav, experiment popover, and avatar menu. */
3
- import { ref, computed, inject } from 'vue'
3
+ import { ref, computed, inject, defineAsyncComponent } from 'vue'
4
4
  import type {
5
5
  PillNavOption,
6
6
  TopBarSettingsConfig,
@@ -16,9 +16,6 @@ import type {
16
16
  } from '../types/components'
17
17
  import { normalizeItemInput } from '../utils/items'
18
18
  import ThemeToggle from './ThemeToggle.vue'
19
- import SettingsModal from './SettingsModal.vue'
20
- import ExperimentPopover from './ExperimentPopover.vue'
21
- import ExperimentSelectorModal from './ExperimentSelectorModal.vue'
22
19
  import AppTopBarPageSelectorInternal from './internal/AppTopBarPageSelectorInternal.vue'
23
20
  import AppTopBarPillNavInternal from './internal/AppTopBarPillNavInternal.vue'
24
21
  import AppAvatarMenu from './AppAvatarMenu.vue'
@@ -101,6 +98,10 @@ const props = withDefaults(defineProps<Props>(), {
101
98
  hasNotificationDot: false,
102
99
  })
103
100
 
101
+ const SettingsModal = defineAsyncComponent(() => import('./SettingsModal.vue'))
102
+ const ExperimentPopover = defineAsyncComponent(() => import('./ExperimentPopover.vue'))
103
+ const ExperimentSelectorModal = defineAsyncComponent(() => import('./ExperimentSelectorModal.vue'))
104
+
104
105
  const emit = defineEmits<{
105
106
  'profile-click': []
106
107
  'admin-click': []
@@ -411,7 +412,7 @@ const effectiveCurrentPageSelectorId = computed(() =>
411
412
  </header>
412
413
 
413
414
  <SettingsModal
414
- v-if="showSettings"
415
+ v-if="showSettings && settingsOpen"
415
416
  v-model="settingsOpen"
416
417
  :title="settingsConfig?.title"
417
418
  :tabs="normalizedSettingsTabs"
@@ -436,7 +437,7 @@ const effectiveCurrentPageSelectorId = computed(() =>
436
437
  </SettingsModal>
437
438
 
438
439
  <ExperimentSelectorModal
439
- v-if="appExperiment && !isStandalone"
440
+ v-if="appExperiment && !isStandalone && appExperiment.selectorModal.value.modelValue"
440
441
  v-bind="appExperiment.selectorModal.value"
441
442
  @update:model-value="$event ? appExperiment.openModal() : appExperiment.closeModal()"
442
443
  @select="appExperiment.handleSelect($event)"
@@ -60,6 +60,8 @@ interface Props {
60
60
  selectable?: boolean
61
61
  /** Currently selected row keys */
62
62
  selectedKeys?: (string | number)[]
63
+ /** Make each body row act as a click/keyboard target. */
64
+ clickableRows?: boolean
63
65
  }
64
66
 
65
67
  const props = withDefaults(defineProps<Props>(), {
@@ -75,6 +77,7 @@ const props = withDefaults(defineProps<Props>(), {
75
77
  searchPlaceholder: 'Search...',
76
78
  selectable: false,
77
79
  selectedKeys: () => [],
80
+ clickableRows: false,
78
81
  })
79
82
 
80
83
  /**
@@ -181,6 +184,24 @@ function handlePageChange(page: number) {
181
184
  emit('update:pagination', { ...props.pagination, page })
182
185
  }
183
186
 
187
+ function handleRowClick(row: Record<string, unknown>, index: number) {
188
+ emit('row-click', row, index)
189
+ }
190
+
191
+ function handleRowKeydown(event: KeyboardEvent, row: Record<string, unknown>, index: number) {
192
+ if (!props.clickableRows) return
193
+ if (event.key !== 'Enter' && event.key !== ' ') return
194
+ event.preventDefault()
195
+ handleRowClick(row, index)
196
+ }
197
+
198
+ function handleCellClick(row: Record<string, unknown>, col: DataFrameColumn, index: number) {
199
+ emit('cell-click', getCellValue(row, col.key), col, row)
200
+ if (props.clickableRows) {
201
+ handleRowClick(row, index)
202
+ }
203
+ }
204
+
184
205
  function isColumnSortable(col: DataFrameColumn): boolean {
185
206
  return col.sortable === true || (props.sortable && col.sortable !== false)
186
207
  }
@@ -310,9 +331,13 @@ const wrapperStyle = computed(() => {
310
331
  {
311
332
  'mint-dataframe__row--striped': striped && rowIndex % 2 === 1,
312
333
  'mint-dataframe__row--selected': rowSelection.isSelected(getRowKey(row, rowIndex)),
334
+ 'mint-dataframe__row--clickable': clickableRows,
313
335
  },
314
336
  ]"
315
- @click="emit('row-click', row, rowIndex)"
337
+ :tabindex="clickableRows ? 0 : undefined"
338
+ :role="clickableRows ? 'button' : undefined"
339
+ @click="handleRowClick(row, rowIndex)"
340
+ @keydown="handleRowKeydown($event, row, rowIndex)"
316
341
  >
317
342
  <td v-if="selectable" class="mint-dataframe__td mint-dataframe__td--checkbox">
318
343
  <input
@@ -332,7 +357,7 @@ const wrapperStyle = computed(() => {
332
357
  `mint-dataframe__td--align-${col.align ?? 'left'}`,
333
358
  { 'mint-dataframe__td--ellipsis': col.ellipsis },
334
359
  ]"
335
- @click.stop="emit('cell-click', getCellValue(row, col.key), col, row)"
360
+ @click.stop="handleCellClick(row, col, rowIndex)"
336
361
  >
337
362
  <slot :name="`cell-${col.key}`" :value="getCellValue(row, col.key)" :row="row" :column="col" :index="rowIndex">
338
363
  {{ formatCell(col, row, rowIndex) }}
@@ -22,12 +22,14 @@ interface Props {
22
22
  loading?: boolean
23
23
  downloadJsonUrl?: string
24
24
  downloadCsvUrl?: string
25
+ autoFetch?: boolean
25
26
  }
26
27
 
27
28
  const props = withDefaults(defineProps<Props>(), {
28
29
  title: 'Data',
29
30
  defaultView: 'summary',
30
31
  loading: false,
32
+ autoFetch: true,
31
33
  })
32
34
 
33
35
  // Auto-fetch when experimentId is provided and no prop data is given
@@ -39,7 +41,7 @@ const hasPropData = computed(() =>
39
41
  watch(
40
42
  () => props.experimentId,
41
43
  (id) => {
42
- if (id && !hasPropData.value) {
44
+ if (props.autoFetch && id && !hasPropData.value) {
43
45
  expData.fetch(id)
44
46
  }
45
47
  },
@@ -158,6 +160,7 @@ function handleDownloadCsv() {
158
160
  Open in {{ pluginName || 'Plugin' }}
159
161
  </BaseButton>
160
162
  <BaseButton
163
+ v-if="downloadJsonUrl"
161
164
  variant="ghost"
162
165
  size="sm"
163
166
  @click="handleDownloadJson"
@@ -165,6 +168,7 @@ function handleDownloadCsv() {
165
168
  JSON
166
169
  </BaseButton>
167
170
  <BaseButton
171
+ v-if="downloadCsvUrl"
168
172
  variant="ghost"
169
173
  size="sm"
170
174
  @click="handleDownloadCsv"
@@ -3,6 +3,27 @@ import { reactive } from 'vue'
3
3
  import PluginIcon from './PluginIcon.vue'
4
4
 
5
5
  const SAMPLE_PATH = 'M13 10V3L4 14h7v7l9-11h-7z'
6
+ const SAMPLE_STRUCTURED = JSON.stringify({
7
+ type: 'mint-icon',
8
+ viewBox: '0 0 24 24',
9
+ gradient: {
10
+ type: 'linear',
11
+ angle: 135,
12
+ stops: [
13
+ { offset: '0%', color: '#AAECBF' },
14
+ { offset: '100%', color: '#14A1A9' },
15
+ ],
16
+ },
17
+ background: { fill: 'gradient', radius: 5 },
18
+ paths: [
19
+ {
20
+ d: 'M7 17c5.8-.2 9.5-3.8 10-10-5.7.5-9.3 4.2-10 10zM8.5 15.5c2.2-1.7 4.1-3.4 6-6',
21
+ fill: 'none',
22
+ stroke: 'white',
23
+ strokeWidth: 1.8,
24
+ },
25
+ ],
26
+ })
6
27
  // Tiny 1x1 transparent PNG.
7
28
  const SAMPLE_PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII='
8
29
  const SAMPLE_HTTPS = 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/4f/Vue.js_Logo_2.svg/64px-Vue.js_Logo_2.svg.png'
@@ -50,16 +71,25 @@ function initState() {
50
71
 
51
72
  <Variant title="Format showcase">
52
73
  <div style="padding: 2rem; display: flex; gap: 1rem; align-items: center;">
74
+ <PluginIcon :icon="SAMPLE_STRUCTURED" size="lg" variant="tinted" />
53
75
  <PluginIcon :icon="SAMPLE_PATH" size="lg" variant="tinted" />
54
76
  <PluginIcon :icon="SAMPLE_PNG" size="lg" variant="tinted" />
55
77
  <PluginIcon :icon="SAMPLE_HTTPS" size="lg" variant="tinted" />
56
78
  <PluginIcon icon="" size="lg" variant="tinted" />
57
79
  </div>
58
80
  <p style="padding: 0 2rem; color: var(--text-muted); font-size: 0.875rem;">
59
- SVG path · data URL (PNG) · https URL · empty (fallback)
81
+ Structured vector · SVG path · data URL (PNG) · https URL · empty (fallback)
60
82
  </p>
61
83
  </Variant>
62
84
 
85
+ <Variant title="Structured vector">
86
+ <div style="padding: 2rem; display: flex; gap: 1rem; align-items: center;">
87
+ <PluginIcon :icon="SAMPLE_STRUCTURED" size="sm" />
88
+ <PluginIcon :icon="SAMPLE_STRUCTURED" size="md" />
89
+ <PluginIcon :icon="SAMPLE_STRUCTURED" size="lg" />
90
+ </div>
91
+ </Variant>
92
+
63
93
  <Variant title="Tone overrides">
64
94
  <div style="padding: 2rem; display: flex; gap: 1rem; align-items: center;">
65
95
  <PluginIcon :icon="SAMPLE_PATH" size="lg" variant="solid" tone="#0EA5E9" />
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  /** Renders a plugin's icon as a sized chip. Auto-detects format:
3
+ * - Structured MINT icon JSON ({"type":"mint-icon",...}) → controlled multi-color SVG
3
4
  * - SVG path data (e.g. "M13 10V3...") → inline <svg><path>
4
5
  * - Raster data URL (data:image/png|jpeg|jpg|gif|webp;...) → <img>
5
6
  * - https:// URL → <img> with no-referrer + lazy loading
@@ -8,7 +9,7 @@
8
9
  * http:// URLs and data:image/svg+xml URLs are deliberately rejected
9
10
  * (mixed-content + XSS, see spec 2026-05-04-plugin-icon-component-design.md). */
10
11
  import { computed } from 'vue'
11
- import { detectPluginIcon, type DetectedPluginIcon } from '../utils/pluginIcon'
12
+ import { detectPluginIcon, type DetectedPluginIcon, type StructuredPluginIcon } from '../utils/pluginIcon'
12
13
 
13
14
  defineOptions({ name: 'PluginIcon' })
14
15
 
@@ -25,6 +26,51 @@ const props = withDefaults(defineProps<Props>(), {
25
26
  })
26
27
 
27
28
  const detected = computed<DetectedPluginIcon>(() => detectPluginIcon(props.icon))
29
+ const structuredIcon = computed<StructuredPluginIcon | undefined>(() =>
30
+ detected.value.format === 'structured' ? detected.value.value as StructuredPluginIcon : undefined,
31
+ )
32
+ const iconValue = computed<string>(() =>
33
+ typeof detected.value.value === 'string' ? detected.value.value : '',
34
+ )
35
+
36
+ const gradientId = computed(() => {
37
+ const source = props.icon ?? ''
38
+ let hash = 0
39
+ for (let index = 0; index < source.length; index += 1) {
40
+ hash = ((hash << 5) - hash + source.charCodeAt(index)) | 0
41
+ }
42
+ return `mint-plugin-icon-gradient-${Math.abs(hash)}`
43
+ })
44
+
45
+ const gradientCoords = computed(() => {
46
+ const angle = structuredIcon.value?.gradient?.angle ?? 135
47
+ const radians = (angle - 90) * Math.PI / 180
48
+ const x = Math.cos(radians) * 50
49
+ const y = Math.sin(radians) * 50
50
+
51
+ return {
52
+ x1: `${50 - x}%`,
53
+ y1: `${50 - y}%`,
54
+ x2: `${50 + x}%`,
55
+ y2: `${50 + y}%`,
56
+ }
57
+ })
58
+
59
+ const structuredViewBox = computed(() => {
60
+ const parts = structuredIcon.value?.viewBox.split(/\s+/).map(Number) ?? [0, 0, 24, 24]
61
+ return {
62
+ x: parts[0],
63
+ y: parts[1],
64
+ width: parts[2],
65
+ height: parts[3],
66
+ }
67
+ })
68
+
69
+ const backgroundFill = computed(() =>
70
+ structuredIcon.value?.background?.fill === 'gradient'
71
+ ? `url(#${gradientId.value})`
72
+ : structuredIcon.value?.background?.fill,
73
+ )
28
74
 
29
75
  const rootClasses = computed(() => [
30
76
  'mint-plugin-icon',
@@ -40,7 +86,51 @@ const rootStyle = computed(() =>
40
86
  <template>
41
87
  <span :class="rootClasses" :style="rootStyle">
42
88
  <svg
43
- v-if="detected.format === 'path' || detected.format === 'fallback'"
89
+ v-if="structuredIcon"
90
+ class="mint-plugin-icon__svg mint-plugin-icon__svg--structured"
91
+ :viewBox="structuredIcon.viewBox"
92
+ aria-hidden="true"
93
+ >
94
+ <defs v-if="structuredIcon.gradient">
95
+ <linearGradient
96
+ :id="gradientId"
97
+ :x1="gradientCoords.x1"
98
+ :y1="gradientCoords.y1"
99
+ :x2="gradientCoords.x2"
100
+ :y2="gradientCoords.y2"
101
+ >
102
+ <stop
103
+ v-for="stop in structuredIcon.gradient.stops"
104
+ :key="`${stop.offset}-${stop.color}`"
105
+ :offset="stop.offset"
106
+ :stop-color="stop.color"
107
+ :stop-opacity="stop.opacity"
108
+ />
109
+ </linearGradient>
110
+ </defs>
111
+ <rect
112
+ v-if="structuredIcon.background"
113
+ :x="structuredViewBox.x"
114
+ :y="structuredViewBox.y"
115
+ :width="structuredViewBox.width"
116
+ :height="structuredViewBox.height"
117
+ :rx="structuredIcon.background.radius"
118
+ :fill="backgroundFill"
119
+ />
120
+ <path
121
+ v-for="(path, index) in structuredIcon.paths"
122
+ :key="index"
123
+ :d="path.d"
124
+ :fill="path.fill ?? 'none'"
125
+ :stroke="path.stroke"
126
+ :stroke-width="path.strokeWidth"
127
+ :opacity="path.opacity"
128
+ stroke-linecap="round"
129
+ stroke-linejoin="round"
130
+ />
131
+ </svg>
132
+ <svg
133
+ v-else-if="detected.format === 'path' || detected.format === 'fallback'"
44
134
  class="mint-plugin-icon__svg"
45
135
  viewBox="0 0 24 24"
46
136
  fill="none"
@@ -50,12 +140,12 @@ const rootStyle = computed(() =>
50
140
  stroke-linejoin="round"
51
141
  aria-hidden="true"
52
142
  >
53
- <path :d="detected.value" />
143
+ <path :d="iconValue" />
54
144
  </svg>
55
145
  <img
56
146
  v-else
57
147
  class="mint-plugin-icon__img"
58
- :src="detected.value"
148
+ :src="iconValue"
59
149
  alt=""
60
150
  referrerpolicy="no-referrer"
61
151
  loading="lazy"
@@ -63,6 +63,7 @@ export interface PluginContract {
63
63
  icon?: string
64
64
  color?: string
65
65
  navItems?: PluginNavItemContract[]
66
+ analysisResultReaders?: string[]
66
67
  capabilities?: Record<string, unknown>
67
68
  }
68
69
  endpoints: PluginEndpointContract[]
@@ -230,21 +231,11 @@ function pluginPageIdFromPath(path: string, fallbackIndex: number): string {
230
231
  return normalizedPath.replace(/^\/+/, '').replace(/\/+/g, '-') || `page-${fallbackIndex + 1}`
231
232
  }
232
233
 
233
- /** Convert PluginMetadata.nav_items into AppTopBar pageSelector items for topbar page switches, homepage PluginCards, and fallback views. */
234
+ /** Convert PluginMetadata.nav_items into AppTopBar pageSelector items for topbar page switches and homepage PluginCards. */
234
235
  export function getPluginPageSelectorItems(contract: PluginContract): PageSelectorItem[] {
235
236
  const pluginIcon = contract.plugin.icon ?? ''
236
237
  const navItems = contract.plugin.navItems ?? []
237
- const source: PluginNavItemContract[] = navItems.length > 0
238
- ? navItems
239
- : [{
240
- path: '/',
241
- label: contract.plugin.name ?? 'Dashboard',
242
- id: 'dashboard',
243
- icon: pluginIcon || undefined,
244
- description: contract.plugin.description,
245
- }]
246
-
247
- return source.map((item, index) => ({
238
+ return navItems.map((item, index) => ({
248
239
  id: item.id || pluginPageIdFromPath(item.path, index),
249
240
  label: item.label,
250
241
  to: normalizePluginNavPath(item.path),
@@ -192,6 +192,7 @@
192
192
  border-right: none !important;
193
193
  border-bottom: 1px solid var(--border-color) !important;
194
194
  background: transparent !important;
195
+ transition: background-color 0.15s ease, box-shadow 0.15s ease;
195
196
  }
196
197
 
197
198
  .mint-dataframe__td--sm {
@@ -248,6 +249,31 @@
248
249
  background-color: var(--bg-hover);
249
250
  }
250
251
 
252
+ .mint-dataframe__row--clickable {
253
+ cursor: pointer;
254
+ }
255
+
256
+ .mint-dataframe__row--clickable:hover > .mint-dataframe__td,
257
+ .mint-dataframe__row--clickable:focus-visible > .mint-dataframe__td {
258
+ background: var(--color-primary-soft) !important;
259
+ box-shadow:
260
+ inset 0 1px 0 rgba(99, 102, 241, 0.18),
261
+ inset 0 -1px 0 rgba(99, 102, 241, 0.18);
262
+ }
263
+
264
+ .mint-dataframe__row--clickable:hover > .mint-dataframe__td:first-child,
265
+ .mint-dataframe__row--clickable:focus-visible > .mint-dataframe__td:first-child {
266
+ box-shadow:
267
+ inset 3px 0 0 var(--color-primary),
268
+ inset 0 1px 0 rgba(99, 102, 241, 0.18),
269
+ inset 0 -1px 0 rgba(99, 102, 241, 0.18);
270
+ }
271
+
272
+ .mint-dataframe__row--clickable:focus-visible {
273
+ outline: 2px solid var(--focus-ring-color, var(--color-primary));
274
+ outline-offset: -2px;
275
+ }
276
+
251
277
  .mint-dataframe__row--striped {
252
278
  background-color: var(--bg-tertiary);
253
279
  }
@@ -36,3 +36,8 @@
36
36
  .mint-plugin-icon__img {
37
37
  object-fit: contain;
38
38
  }
39
+
40
+ .mint-plugin-icon .mint-plugin-icon__svg--structured {
41
+ width: 100%;
42
+ height: 100%;
43
+ }