@morscherlab/mint-sdk 1.0.0-alpha.9 → 1.0.0-beta.2

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 (81) hide show
  1. package/dist/__tests__/components/PluginIcon.test.d.ts +1 -0
  2. package/dist/components/AppTopBar.vue.d.ts +2 -0
  3. package/dist/components/BaseButton.vue.d.ts +1 -1
  4. package/dist/components/BaseCheckbox.vue.d.ts +1 -1
  5. package/dist/components/BaseInput.vue.d.ts +1 -1
  6. package/dist/components/BasePill.vue.d.ts +1 -1
  7. package/dist/components/BaseRadioGroup.vue.d.ts +1 -1
  8. package/dist/components/BaseSelect.vue.d.ts +1 -1
  9. package/dist/components/BaseSlider.vue.d.ts +1 -1
  10. package/dist/components/BaseTextarea.vue.d.ts +1 -1
  11. package/dist/components/BaseToggle.vue.d.ts +1 -1
  12. package/dist/components/ColorSlider.vue.d.ts +1 -1
  13. package/dist/components/ConcentrationInput.vue.d.ts +1 -1
  14. package/dist/components/DatePicker.vue.d.ts +1 -1
  15. package/dist/components/DateTimePicker.vue.d.ts +1 -1
  16. package/dist/components/Divider.vue.d.ts +1 -1
  17. package/dist/components/DropdownButton.vue.d.ts +1 -1
  18. package/dist/components/FileUploader.vue.d.ts +1 -1
  19. package/dist/components/FormulaInput.vue.d.ts +1 -1
  20. package/dist/components/IconButton.vue.d.ts +1 -1
  21. package/dist/components/LoadingSpinner.vue.d.ts +1 -1
  22. package/dist/components/MultiSelect.vue.d.ts +1 -1
  23. package/dist/components/NumberInput.vue.d.ts +1 -1
  24. package/dist/components/PluginIcon.vue.d.ts +11 -0
  25. package/dist/components/ProgressBar.vue.d.ts +1 -1
  26. package/dist/components/ReagentEditor.vue.d.ts +1 -1
  27. package/dist/components/ResourceCard.vue.d.ts +1 -1
  28. package/dist/components/SampleSelector.vue.d.ts +1 -1
  29. package/dist/components/ScientificNumber.vue.d.ts +1 -1
  30. package/dist/components/SegmentedControl.vue.d.ts +1 -1
  31. package/dist/components/SettingsModal.vue.d.ts +22 -2
  32. package/dist/components/TagsInput.vue.d.ts +1 -1
  33. package/dist/components/TimePicker.vue.d.ts +1 -1
  34. package/dist/components/TimeRangeInput.vue.d.ts +1 -1
  35. package/dist/components/UnitInput.vue.d.ts +1 -1
  36. package/dist/components/WellPlate.vue.d.ts +1 -1
  37. package/dist/components/index.d.ts +1 -0
  38. package/dist/components/index.js +3 -3
  39. package/dist/{components-CzbQQPCb.js → components-_XqPEhP9.js} +572 -362
  40. package/dist/components-_XqPEhP9.js.map +1 -0
  41. package/dist/composables/index.js +2 -2
  42. package/dist/composables/usePlatformContext.d.ts +3 -0
  43. package/dist/{composables-BXklV5ii.js → composables-tiZqLu1M.js} +2 -2
  44. package/dist/{composables-BXklV5ii.js.map → composables-tiZqLu1M.js.map} +1 -1
  45. package/dist/index.d.ts +2 -2
  46. package/dist/index.js +4 -4
  47. package/dist/install.js +2 -2
  48. package/dist/stores/auth.d.ts +1 -1
  49. package/dist/styles.css +896 -553
  50. package/dist/types/components.d.ts +39 -0
  51. package/dist/types/index.d.ts +1 -1
  52. package/dist/types/platform.d.ts +1 -0
  53. package/dist/{useScheduleDrag-CxBeqYcu.js → useScheduleDrag-CA9sGNJG.js} +4000 -4000
  54. package/dist/useScheduleDrag-CA9sGNJG.js.map +1 -0
  55. package/package.json +1 -1
  56. package/src/__tests__/components/AppTopBar.test.ts +31 -13
  57. package/src/__tests__/components/PluginIcon.test.ts +119 -0
  58. package/src/components/AppTopBar.vue +32 -27
  59. package/src/components/PluginIcon.story.vue +71 -0
  60. package/src/components/PluginIcon.vue +88 -0
  61. package/src/components/SettingsModal.story.vue +337 -45
  62. package/src/components/SettingsModal.vue +251 -64
  63. package/src/components/index.ts +1 -0
  64. package/src/index.ts +4 -0
  65. package/src/styles/components/app-pill-nav.css +1 -2
  66. package/src/styles/components/app-top-bar.css +1 -2
  67. package/src/styles/components/button.css +3 -7
  68. package/src/styles/components/dropdown-button.css +4 -4
  69. package/src/styles/components/input.css +4 -5
  70. package/src/styles/components/number-input.css +3 -3
  71. package/src/styles/components/plugin-icon.css +38 -0
  72. package/src/styles/components/segmented-control.css +4 -7
  73. package/src/styles/components/settings-modal.css +184 -0
  74. package/src/styles/components/tabs.css +1 -2
  75. package/src/styles/components/textarea.css +4 -5
  76. package/src/styles/components/unit-input.css +3 -3
  77. package/src/types/components.ts +42 -0
  78. package/src/types/index.ts +3 -0
  79. package/src/types/platform.ts +1 -0
  80. package/dist/components-CzbQQPCb.js.map +0 -1
  81. package/dist/useScheduleDrag-CxBeqYcu.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morscherlab/mint-sdk",
3
- "version": "1.0.0-alpha.9",
3
+ "version": "1.0.0-beta.2",
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",
@@ -621,10 +621,10 @@ describe('AppTopBar', () => {
621
621
 
622
622
  expect(wrapper.find('.mint-topbar__logo-text').exists()).toBe(true)
623
623
  expect(wrapper.find('.mint-topbar__logo-text').text()).toBe('M')
624
- expect(wrapper.find('.mint-topbar__logo-svg').exists()).toBe(false)
624
+ expect(wrapper.findComponent({ name: 'PluginIcon' }).exists()).toBe(false)
625
625
  })
626
626
 
627
- it('should render plugin metadata icon SVG when plugin has icon', () => {
627
+ it('should render PluginIcon when plugin metadata has an icon', () => {
628
628
  const iconPath = 'M13 10V3L4 14h7v7l9-11h-7z'
629
629
  mockPlatformCtx({
630
630
  isIntegrated: true,
@@ -633,13 +633,31 @@ describe('AppTopBar', () => {
633
633
 
634
634
  const wrapper = createWrapper({ title: 'Test App' })
635
635
 
636
- const svg = wrapper.find('.mint-topbar__logo-svg')
637
- expect(svg.exists()).toBe(true)
638
- expect(svg.find('path').attributes('d')).toBe(iconPath)
636
+ const pluginIcon = wrapper.findComponent({ name: 'PluginIcon' })
637
+ expect(pluginIcon.exists()).toBe(true)
638
+ expect(pluginIcon.props('icon')).toBe(iconPath)
639
+ expect(pluginIcon.props('variant')).toBe('solid')
640
+ expect(pluginIcon.props('size')).toBe('md')
639
641
  expect(wrapper.find('.mint-topbar__logo-text').exists()).toBe(false)
640
642
  })
641
643
 
642
- it('should not render plugin icon when #icon slot is provided', () => {
644
+ it('should pass plugin.color to PluginIcon as tone', () => {
645
+ const iconPath = 'M13 10V3L4 14h7v7l9-11h-7z'
646
+ mockPlatformCtx({
647
+ isIntegrated: true,
648
+ plugin: {
649
+ id: 'test', name: 'Test', version: '1.0',
650
+ icon: iconPath, color: '#22C55E',
651
+ route_prefix: '/test', api_prefix: '/api/test',
652
+ },
653
+ })
654
+
655
+ const wrapper = createWrapper({ title: 'Test App' })
656
+
657
+ expect(wrapper.findComponent({ name: 'PluginIcon' }).props('tone')).toBe('#22C55E')
658
+ })
659
+
660
+ it('should not render PluginIcon when #icon slot is provided', () => {
643
661
  const iconPath = 'M13 10V3L4 14h7v7l9-11h-7z'
644
662
  mockPlatformCtx({
645
663
  isIntegrated: true,
@@ -651,10 +669,10 @@ describe('AppTopBar', () => {
651
669
  })
652
670
 
653
671
  expect(wrapper.find('.custom-icon').exists()).toBe(true)
654
- expect(wrapper.find('.mint-topbar__logo-svg').exists()).toBe(false)
672
+ expect(wrapper.findComponent({ name: 'PluginIcon' }).exists()).toBe(false)
655
673
  })
656
674
 
657
- it('should not render plugin icon when icon does not start with M', () => {
675
+ it('should still render PluginIcon when icon does not start with M (PluginIcon shows fallback)', () => {
658
676
  mockPlatformCtx({
659
677
  isIntegrated: true,
660
678
  plugin: { id: 'test', name: 'Test', version: '1.0', icon: 'invalid-path', route_prefix: '/test', api_prefix: '/api/test' },
@@ -662,12 +680,12 @@ describe('AppTopBar', () => {
662
680
 
663
681
  const wrapper = createWrapper({ title: 'Test App' })
664
682
 
665
- expect(wrapper.find('.mint-topbar__logo-svg').exists()).toBe(false)
666
- expect(wrapper.find('.mint-topbar__logo-text').exists()).toBe(true)
667
- expect(wrapper.find('.mint-topbar__logo-text').text()).toBe('M')
683
+ // Topbar passes any non-empty icon string to PluginIcon, which decides what to render.
684
+ expect(wrapper.findComponent({ name: 'PluginIcon' }).exists()).toBe(true)
685
+ expect(wrapper.find('.mint-topbar__logo-text').exists()).toBe(false)
668
686
  })
669
687
 
670
- it('should not render plugin icon when icon is empty string', () => {
688
+ it('should not render PluginIcon when icon is empty string', () => {
671
689
  mockPlatformCtx({
672
690
  isIntegrated: true,
673
691
  plugin: { id: 'test', name: 'Test', version: '1.0', icon: '', route_prefix: '/test', api_prefix: '/api/test' },
@@ -675,7 +693,7 @@ describe('AppTopBar', () => {
675
693
 
676
694
  const wrapper = createWrapper({ title: 'Test App' })
677
695
 
678
- expect(wrapper.find('.mint-topbar__logo-svg').exists()).toBe(false)
696
+ expect(wrapper.findComponent({ name: 'PluginIcon' }).exists()).toBe(false)
679
697
  expect(wrapper.find('.mint-topbar__logo-text').exists()).toBe(true)
680
698
  expect(wrapper.find('.mint-topbar__logo-text').text()).toBe('M')
681
699
  })
@@ -0,0 +1,119 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import PluginIcon from '../../components/PluginIcon.vue'
4
+
5
+ const SAMPLE_PATH = 'M13 10V3L4 14h7v7l9-11h-7z'
6
+ const SAMPLE_HTTPS_URL = 'https://example.com/icon.png'
7
+
8
+ describe('PluginIcon', () => {
9
+ describe('format detection', () => {
10
+ it('should render SVG path when icon starts with M followed by digit', () => {
11
+ const wrapper = mount(PluginIcon, { props: { icon: SAMPLE_PATH } })
12
+ const path = wrapper.find('svg path')
13
+ expect(path.exists()).toBe(true)
14
+ expect(path.attributes('d')).toBe(SAMPLE_PATH)
15
+ expect(wrapper.find('img').exists()).toBe(false)
16
+ })
17
+
18
+ it('should render SVG path when icon starts with lowercase m and a space', () => {
19
+ const wrapper = mount(PluginIcon, { props: { icon: 'm 5,5 l 10,10' } })
20
+ const path = wrapper.find('svg path')
21
+ expect(path.exists()).toBe(true)
22
+ expect(path.attributes('d')).toBe('m 5,5 l 10,10')
23
+ })
24
+
25
+ it('should render SVG path after stripping leading whitespace', () => {
26
+ const wrapper = mount(PluginIcon, { props: { icon: ' ' + SAMPLE_PATH } })
27
+ const path = wrapper.find('svg path')
28
+ expect(path.exists()).toBe(true)
29
+ expect(path.attributes('d')).toBe(SAMPLE_PATH)
30
+ })
31
+
32
+ it('should render SVG path after stripping leading BOM', () => {
33
+ const wrapper = mount(PluginIcon, { props: { icon: '' + SAMPLE_PATH } })
34
+ const path = wrapper.find('svg path')
35
+ expect(path.exists()).toBe(true)
36
+ expect(path.attributes('d')).toBe(SAMPLE_PATH)
37
+ })
38
+
39
+ it('should render <img> for raster data URLs (png, jpeg, gif, webp)', () => {
40
+ for (const mime of ['png', 'jpeg', 'jpg', 'gif', 'webp']) {
41
+ const url = `data:image/${mime};base64,iVBORw0KGgo=`
42
+ const wrapper = mount(PluginIcon, { props: { icon: url } })
43
+ expect(wrapper.find('img').attributes('src')).toBe(url)
44
+ expect(wrapper.find('svg path').exists()).toBe(false)
45
+ }
46
+ })
47
+
48
+ it('should reject data:image/svg+xml as fallback (security)', () => {
49
+ const svgDataUrl = 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4='
50
+ const wrapper = mount(PluginIcon, { props: { icon: svgDataUrl } })
51
+ expect(wrapper.find('img').exists()).toBe(false)
52
+ expect(wrapper.find('svg path').exists()).toBe(true)
53
+ expect(wrapper.find('svg path').attributes('d')).not.toBe(svgDataUrl)
54
+ })
55
+
56
+ it('should render <img> for https URLs with referrer/lazy attributes', () => {
57
+ const wrapper = mount(PluginIcon, { props: { icon: SAMPLE_HTTPS_URL } })
58
+ const img = wrapper.find('img')
59
+ expect(img.attributes('src')).toBe(SAMPLE_HTTPS_URL)
60
+ expect(img.attributes('referrerpolicy')).toBe('no-referrer')
61
+ expect(img.attributes('loading')).toBe('lazy')
62
+ })
63
+
64
+ it('should reject http URLs as fallback (mixed-content)', () => {
65
+ const wrapper = mount(PluginIcon, { props: { icon: 'http://example.com/icon.png' } })
66
+ expect(wrapper.find('img').exists()).toBe(false)
67
+ expect(wrapper.find('svg path').exists()).toBe(true)
68
+ })
69
+
70
+ it('should render fallback path when icon is empty', () => {
71
+ const wrapper = mount(PluginIcon, { props: { icon: '' } })
72
+ expect(wrapper.find('svg path').exists()).toBe(true)
73
+ expect(wrapper.find('img').exists()).toBe(false)
74
+ })
75
+
76
+ it('should render fallback when icon is unrecognized', () => {
77
+ const wrapper = mount(PluginIcon, { props: { icon: 'markdown:foo' } })
78
+ expect(wrapper.find('svg path').exists()).toBe(true)
79
+ expect(wrapper.find('img').exists()).toBe(false)
80
+ })
81
+ })
82
+
83
+ describe('size and variant classes', () => {
84
+ it('should apply size class', () => {
85
+ const wrapper = mount(PluginIcon, { props: { icon: SAMPLE_PATH, size: 'lg' } })
86
+ expect(wrapper.classes()).toContain('mint-plugin-icon--lg')
87
+ })
88
+
89
+ it('should default size to md', () => {
90
+ const wrapper = mount(PluginIcon, { props: { icon: SAMPLE_PATH } })
91
+ expect(wrapper.classes()).toContain('mint-plugin-icon--md')
92
+ })
93
+
94
+ it('should apply variant class', () => {
95
+ const wrapper = mount(PluginIcon, { props: { icon: SAMPLE_PATH, variant: 'tinted' } })
96
+ expect(wrapper.classes()).toContain('mint-plugin-icon--tinted')
97
+ })
98
+
99
+ it('should default variant to solid', () => {
100
+ const wrapper = mount(PluginIcon, { props: { icon: SAMPLE_PATH } })
101
+ expect(wrapper.classes()).toContain('mint-plugin-icon--solid')
102
+ })
103
+ })
104
+
105
+ describe('tone', () => {
106
+ it('should apply tone via inline custom property', () => {
107
+ const wrapper = mount(PluginIcon, { props: { icon: SAMPLE_PATH, tone: '#0EA5E9' } })
108
+ const style = wrapper.attributes('style') || ''
109
+ expect(style).toContain('--mint-plugin-icon-tone')
110
+ expect(style).toContain('#0EA5E9')
111
+ })
112
+
113
+ it('should not apply tone style when undefined', () => {
114
+ const wrapper = mount(PluginIcon, { props: { icon: SAMPLE_PATH } })
115
+ const style = wrapper.attributes('style') || ''
116
+ expect(style).not.toContain('--mint-plugin-icon-tone')
117
+ })
118
+ })
119
+ })
@@ -21,11 +21,10 @@ import AppPageSelector from './AppPageSelector.vue'
21
21
  import AppPillNav from './AppPillNav.vue'
22
22
  import AppAvatarMenu from './AppAvatarMenu.vue'
23
23
  import AppPluginSwitcher from './AppPluginSwitcher.vue'
24
+ import PluginIcon from './PluginIcon.vue'
24
25
  import { usePlatformContext } from '../composables/usePlatformContext'
25
26
  import { APP_EXPERIMENT_KEY } from '../composables/useAppExperiment'
26
27
 
27
- const SVG_PATH_PREFIX = 'M'
28
-
29
28
  interface Props {
30
29
  // Classic title & breadcrumb
31
30
  title?: string
@@ -96,6 +95,7 @@ const emit = defineEmits<{
96
95
  'account-menu-select': [item: AccountMenuItem]
97
96
  'sign-out': []
98
97
  'notifications-click': []
98
+ 'settings-values-change': [data: Record<string, unknown>]
99
99
  }>()
100
100
 
101
101
  const settingsOpen = ref(false)
@@ -103,10 +103,7 @@ const { isIntegrated, plugin } = usePlatformContext()
103
103
  const isStandalone = computed(() => !isIntegrated.value)
104
104
  const appExperiment = inject(APP_EXPERIMENT_KEY, null)
105
105
 
106
- const pluginIcon = computed(() => {
107
- const icon = plugin.value?.icon
108
- return icon && icon.startsWith(SVG_PATH_PREFIX) ? icon : null
109
- })
106
+ const hasPluginIcon = computed(() => !!plugin.value?.icon)
110
107
 
111
108
  const profileInitial = computed(() => {
112
109
  if (props.userInitial) return props.userInitial
@@ -224,13 +221,14 @@ onUnmounted(() => {
224
221
  class="mint-topbar-home-link"
225
222
  >
226
223
  <slot name="icon">
227
- <div v-if="pluginIcon" class="mint-topbar__logo">
228
- <div class="mint-topbar__logo-icon">
229
- <svg class="mint-topbar__logo-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
230
- <path :d="pluginIcon" />
231
- </svg>
232
- </div>
233
- </div>
224
+ <PluginIcon
225
+ v-if="hasPluginIcon"
226
+ class="mint-topbar__plugin-icon"
227
+ :icon="plugin!.icon"
228
+ :tone="plugin!.color"
229
+ size="md"
230
+ variant="solid"
231
+ />
234
232
  <slot v-else name="logo">
235
233
  <div v-if="showLogo" class="mint-topbar__logo">
236
234
  <div class="mint-topbar__logo-icon">
@@ -246,13 +244,14 @@ onUnmounted(() => {
246
244
  class="mint-topbar-home-link"
247
245
  >
248
246
  <slot name="icon">
249
- <div v-if="pluginIcon" class="mint-topbar__logo">
250
- <div class="mint-topbar__logo-icon">
251
- <svg class="mint-topbar__logo-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
252
- <path :d="pluginIcon" />
253
- </svg>
254
- </div>
255
- </div>
247
+ <PluginIcon
248
+ v-if="hasPluginIcon"
249
+ class="mint-topbar__plugin-icon"
250
+ :icon="plugin!.icon"
251
+ :tone="plugin!.color"
252
+ size="md"
253
+ variant="solid"
254
+ />
256
255
  <slot v-else name="logo">
257
256
  <div v-if="showLogo" class="mint-topbar__logo">
258
257
  <div class="mint-topbar__logo-icon">
@@ -264,13 +263,14 @@ onUnmounted(() => {
264
263
  </router-link>
265
264
  <template v-else>
266
265
  <slot name="icon">
267
- <div v-if="pluginIcon" class="mint-topbar__logo">
268
- <div class="mint-topbar__logo-icon">
269
- <svg class="mint-topbar__logo-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
270
- <path :d="pluginIcon" />
271
- </svg>
272
- </div>
273
- </div>
266
+ <PluginIcon
267
+ v-if="hasPluginIcon"
268
+ class="mint-topbar__plugin-icon"
269
+ :icon="plugin!.icon"
270
+ :tone="plugin!.color"
271
+ size="md"
272
+ variant="solid"
273
+ />
274
274
  <slot v-else name="logo">
275
275
  <div v-if="showLogo" class="mint-topbar__logo">
276
276
  <div class="mint-topbar__logo-icon">
@@ -637,6 +637,11 @@ onUnmounted(() => {
637
637
  :tabs="settingsConfig?.tabs"
638
638
  :show-appearance="settingsConfig?.showAppearance ?? true"
639
639
  :size="settingsConfig?.size"
640
+ :layout="settingsConfig?.layout"
641
+ :schema="settingsConfig?.schema"
642
+ :values="settingsConfig?.values"
643
+ :enhancements="settingsConfig?.enhancements"
644
+ @update:values="emit('settings-values-change', $event)"
640
645
  >
641
646
  <template v-for="tab in (settingsConfig?.tabs ?? [])" :key="tab.id" #[`tab-${tab.id}`]>
642
647
  <slot :name="`settings-tab-${tab.id}`" />
@@ -0,0 +1,71 @@
1
+ <script setup lang="ts">
2
+ import { reactive } from 'vue'
3
+ import PluginIcon from './PluginIcon.vue'
4
+
5
+ const SAMPLE_PATH = 'M13 10V3L4 14h7v7l9-11h-7z'
6
+ // Tiny 1x1 transparent PNG.
7
+ const SAMPLE_PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII='
8
+ const SAMPLE_HTTPS = 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/4f/Vue.js_Logo_2.svg/64px-Vue.js_Logo_2.svg.png'
9
+
10
+ function initState() {
11
+ return reactive({
12
+ icon: SAMPLE_PATH,
13
+ size: 'md' as 'sm' | 'md' | 'lg',
14
+ variant: 'solid' as 'solid' | 'tinted',
15
+ tone: '',
16
+ })
17
+ }
18
+ </script>
19
+
20
+ <template>
21
+ <Story title="Brand/PluginIcon" :layout="{ type: 'grid', width: 320 }">
22
+ <Variant title="Playground" :init-state="initState">
23
+ <template #default="{ state }">
24
+ <div style="padding: 2rem; max-width: 600px;">
25
+ <PluginIcon :icon="state.icon" :size="state.size" :variant="state.variant" :tone="state.tone || undefined" />
26
+ </div>
27
+ </template>
28
+ <template #controls="{ state }">
29
+ <HstText v-model="state.icon" title="icon" />
30
+ <HstSelect v-model="state.size" title="size" :options="['sm', 'md', 'lg']" />
31
+ <HstSelect v-model="state.variant" title="variant" :options="['solid', 'tinted']" />
32
+ <HstText v-model="state.tone" title="tone (hex)" />
33
+ </template>
34
+ </Variant>
35
+
36
+ <Variant title="Sizes">
37
+ <div style="padding: 2rem; display: flex; gap: 1rem; align-items: center;">
38
+ <PluginIcon :icon="SAMPLE_PATH" size="sm" />
39
+ <PluginIcon :icon="SAMPLE_PATH" size="md" />
40
+ <PluginIcon :icon="SAMPLE_PATH" size="lg" />
41
+ </div>
42
+ </Variant>
43
+
44
+ <Variant title="Variants">
45
+ <div style="padding: 2rem; display: flex; gap: 1rem; align-items: center;">
46
+ <PluginIcon :icon="SAMPLE_PATH" variant="solid" />
47
+ <PluginIcon :icon="SAMPLE_PATH" variant="tinted" />
48
+ </div>
49
+ </Variant>
50
+
51
+ <Variant title="Format showcase">
52
+ <div style="padding: 2rem; display: flex; gap: 1rem; align-items: center;">
53
+ <PluginIcon :icon="SAMPLE_PATH" size="lg" variant="tinted" />
54
+ <PluginIcon :icon="SAMPLE_PNG" size="lg" variant="tinted" />
55
+ <PluginIcon :icon="SAMPLE_HTTPS" size="lg" variant="tinted" />
56
+ <PluginIcon icon="" size="lg" variant="tinted" />
57
+ </div>
58
+ <p style="padding: 0 2rem; color: var(--text-muted); font-size: 0.875rem;">
59
+ SVG path · data URL (PNG) · https URL · empty (fallback)
60
+ </p>
61
+ </Variant>
62
+
63
+ <Variant title="Tone overrides">
64
+ <div style="padding: 2rem; display: flex; gap: 1rem; align-items: center;">
65
+ <PluginIcon :icon="SAMPLE_PATH" size="lg" variant="solid" tone="#0EA5E9" />
66
+ <PluginIcon :icon="SAMPLE_PATH" size="lg" variant="solid" tone="#F97316" />
67
+ <PluginIcon :icon="SAMPLE_PATH" size="lg" variant="solid" tone="#22C55E" />
68
+ </div>
69
+ </Variant>
70
+ </Story>
71
+ </template>
@@ -0,0 +1,88 @@
1
+ <script setup lang="ts">
2
+ /** Renders a plugin's icon as a sized chip. Auto-detects format:
3
+ * - SVG path data (e.g. "M13 10V3...") → inline <svg><path>
4
+ * - Raster data URL (data:image/png|jpeg|jpg|gif|webp;...) → <img>
5
+ * - https:// URL → <img> with no-referrer + lazy loading
6
+ * - Anything else → MINT canonical plugin placeholder path
7
+ *
8
+ * http:// URLs and data:image/svg+xml URLs are deliberately rejected
9
+ * (mixed-content + XSS, see spec 2026-05-04-plugin-icon-component-design.md). */
10
+ import { computed } from 'vue'
11
+
12
+ defineOptions({ name: 'PluginIcon' })
13
+
14
+ interface Props {
15
+ icon?: string
16
+ size?: 'sm' | 'md' | 'lg'
17
+ variant?: 'solid' | 'tinted'
18
+ tone?: string
19
+ }
20
+
21
+ const props = withDefaults(defineProps<Props>(), {
22
+ size: 'md',
23
+ variant: 'solid',
24
+ })
25
+
26
+ // MINT canonical plugin placeholder — page-with-corner-fold glyph.
27
+ const 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'
28
+
29
+ const PATH_REGEX = /^[Mm][\s,\d\-.]/
30
+ const RASTER_DATA_URL_REGEX = /^data:image\/(png|jpeg|jpg|gif|webp);/
31
+
32
+ type Format = 'path' | 'data-url' | 'https-url' | 'fallback'
33
+
34
+ interface Detected {
35
+ format: Format
36
+ value: string
37
+ }
38
+
39
+ const detected = computed<Detected>(() => {
40
+ // Strip leading whitespace and BOM before pattern checks.
41
+ const raw = (props.icon ?? '').replace(/^[\s]+/, '')
42
+ if (!raw) return { format: 'fallback', value: FALLBACK_PATH }
43
+ if (PATH_REGEX.test(raw)) return { format: 'path', value: raw }
44
+ if (RASTER_DATA_URL_REGEX.test(raw)) return { format: 'data-url', value: raw }
45
+ if (raw.startsWith('https://')) return { format: 'https-url', value: raw }
46
+ return { format: 'fallback', value: FALLBACK_PATH }
47
+ })
48
+
49
+ const rootClasses = computed(() => [
50
+ 'mint-plugin-icon',
51
+ `mint-plugin-icon--${props.size}`,
52
+ `mint-plugin-icon--${props.variant}`,
53
+ ])
54
+
55
+ const rootStyle = computed(() =>
56
+ props.tone ? { '--mint-plugin-icon-tone': props.tone } : undefined,
57
+ )
58
+ </script>
59
+
60
+ <template>
61
+ <span :class="rootClasses" :style="rootStyle">
62
+ <svg
63
+ v-if="detected.format === 'path' || detected.format === 'fallback'"
64
+ class="mint-plugin-icon__svg"
65
+ viewBox="0 0 24 24"
66
+ fill="none"
67
+ stroke="currentColor"
68
+ stroke-width="2"
69
+ stroke-linecap="round"
70
+ stroke-linejoin="round"
71
+ aria-hidden="true"
72
+ >
73
+ <path :d="detected.value" />
74
+ </svg>
75
+ <img
76
+ v-else
77
+ class="mint-plugin-icon__img"
78
+ :src="detected.value"
79
+ alt=""
80
+ referrerpolicy="no-referrer"
81
+ loading="lazy"
82
+ />
83
+ </span>
84
+ </template>
85
+
86
+ <style>
87
+ @import '../styles/components/plugin-icon.css';
88
+ </style>