@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.
- package/dist/__tests__/components/PluginIcon.test.d.ts +1 -0
- package/dist/components/AppTopBar.vue.d.ts +2 -0
- package/dist/components/BaseButton.vue.d.ts +1 -1
- package/dist/components/BaseCheckbox.vue.d.ts +1 -1
- package/dist/components/BaseInput.vue.d.ts +1 -1
- package/dist/components/BasePill.vue.d.ts +1 -1
- package/dist/components/BaseRadioGroup.vue.d.ts +1 -1
- package/dist/components/BaseSelect.vue.d.ts +1 -1
- package/dist/components/BaseSlider.vue.d.ts +1 -1
- package/dist/components/BaseTextarea.vue.d.ts +1 -1
- package/dist/components/BaseToggle.vue.d.ts +1 -1
- package/dist/components/ColorSlider.vue.d.ts +1 -1
- package/dist/components/ConcentrationInput.vue.d.ts +1 -1
- package/dist/components/DatePicker.vue.d.ts +1 -1
- package/dist/components/DateTimePicker.vue.d.ts +1 -1
- package/dist/components/Divider.vue.d.ts +1 -1
- package/dist/components/DropdownButton.vue.d.ts +1 -1
- package/dist/components/FileUploader.vue.d.ts +1 -1
- package/dist/components/FormulaInput.vue.d.ts +1 -1
- package/dist/components/IconButton.vue.d.ts +1 -1
- package/dist/components/LoadingSpinner.vue.d.ts +1 -1
- package/dist/components/MultiSelect.vue.d.ts +1 -1
- package/dist/components/NumberInput.vue.d.ts +1 -1
- package/dist/components/PluginIcon.vue.d.ts +11 -0
- package/dist/components/ProgressBar.vue.d.ts +1 -1
- package/dist/components/ReagentEditor.vue.d.ts +1 -1
- package/dist/components/ResourceCard.vue.d.ts +1 -1
- package/dist/components/SampleSelector.vue.d.ts +1 -1
- package/dist/components/ScientificNumber.vue.d.ts +1 -1
- package/dist/components/SegmentedControl.vue.d.ts +1 -1
- package/dist/components/SettingsModal.vue.d.ts +22 -2
- package/dist/components/TagsInput.vue.d.ts +1 -1
- package/dist/components/TimePicker.vue.d.ts +1 -1
- package/dist/components/TimeRangeInput.vue.d.ts +1 -1
- package/dist/components/UnitInput.vue.d.ts +1 -1
- package/dist/components/WellPlate.vue.d.ts +1 -1
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +3 -3
- package/dist/{components-CzbQQPCb.js → components-_XqPEhP9.js} +572 -362
- package/dist/components-_XqPEhP9.js.map +1 -0
- package/dist/composables/index.js +2 -2
- package/dist/composables/usePlatformContext.d.ts +3 -0
- package/dist/{composables-BXklV5ii.js → composables-tiZqLu1M.js} +2 -2
- package/dist/{composables-BXklV5ii.js.map → composables-tiZqLu1M.js.map} +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +4 -4
- package/dist/install.js +2 -2
- package/dist/stores/auth.d.ts +1 -1
- package/dist/styles.css +896 -553
- package/dist/types/components.d.ts +39 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/platform.d.ts +1 -0
- package/dist/{useScheduleDrag-CxBeqYcu.js → useScheduleDrag-CA9sGNJG.js} +4000 -4000
- package/dist/useScheduleDrag-CA9sGNJG.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/components/AppTopBar.test.ts +31 -13
- package/src/__tests__/components/PluginIcon.test.ts +119 -0
- package/src/components/AppTopBar.vue +32 -27
- package/src/components/PluginIcon.story.vue +71 -0
- package/src/components/PluginIcon.vue +88 -0
- package/src/components/SettingsModal.story.vue +337 -45
- package/src/components/SettingsModal.vue +251 -64
- package/src/components/index.ts +1 -0
- package/src/index.ts +4 -0
- package/src/styles/components/app-pill-nav.css +1 -2
- package/src/styles/components/app-top-bar.css +1 -2
- package/src/styles/components/button.css +3 -7
- package/src/styles/components/dropdown-button.css +4 -4
- package/src/styles/components/input.css +4 -5
- package/src/styles/components/number-input.css +3 -3
- package/src/styles/components/plugin-icon.css +38 -0
- package/src/styles/components/segmented-control.css +4 -7
- package/src/styles/components/settings-modal.css +184 -0
- package/src/styles/components/tabs.css +1 -2
- package/src/styles/components/textarea.css +4 -5
- package/src/styles/components/unit-input.css +3 -3
- package/src/types/components.ts +42 -0
- package/src/types/index.ts +3 -0
- package/src/types/platform.ts +1 -0
- package/dist/components-CzbQQPCb.js.map +0 -1
- 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-
|
|
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.
|
|
624
|
+
expect(wrapper.findComponent({ name: 'PluginIcon' }).exists()).toBe(false)
|
|
625
625
|
})
|
|
626
626
|
|
|
627
|
-
it('should render
|
|
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
|
|
637
|
-
expect(
|
|
638
|
-
expect(
|
|
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
|
|
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.
|
|
672
|
+
expect(wrapper.findComponent({ name: 'PluginIcon' }).exists()).toBe(false)
|
|
655
673
|
})
|
|
656
674
|
|
|
657
|
-
it('should
|
|
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
|
-
|
|
666
|
-
expect(wrapper.
|
|
667
|
-
expect(wrapper.find('.mint-topbar__logo-text').
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
<
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
<
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
<
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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>
|