@morscherlab/mint-sdk 1.1.5 → 1.1.6

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 (96) hide show
  1. package/dist/{EmptyState-LMPS3keb.js → EmptyState-p90quq8Z.js} +2 -2
  2. package/dist/{EmptyState-LMPS3keb.js.map → EmptyState-p90quq8Z.js.map} +1 -1
  3. package/dist/{ExperimentPopover-D6_RGzTY.js → ExperimentPopover-tLwJ7XF4.js} +3 -3
  4. package/dist/{ExperimentPopover-D6_RGzTY.js.map → ExperimentPopover-tLwJ7XF4.js.map} +1 -1
  5. package/dist/{ExperimentSelectorModal-DcQ38zua.js → ExperimentSelectorModal-B6P2msT4.js} +12 -5
  6. package/dist/ExperimentSelectorModal-B6P2msT4.js.map +1 -0
  7. package/dist/{SettingsModal-Ccq2z-Ms.js → SettingsModal-D7LFUokT.js} +100 -156
  8. package/dist/SettingsModal-D7LFUokT.js.map +1 -0
  9. package/dist/__tests__/components/ActionMenu.test.d.ts +1 -0
  10. package/dist/__tests__/components/BaseModal.test.d.ts +1 -0
  11. package/dist/components/ActionMenu.vue.d.ts +21 -0
  12. package/dist/components/BaseModal.vue.d.ts +17 -1
  13. package/dist/components/GeneratedPluginView.vue.d.ts +3 -3
  14. package/dist/components/GeneratedResultRenderer.vue.d.ts +3 -3
  15. package/dist/components/index.d.ts +1 -0
  16. package/dist/components/index.js +8 -8
  17. package/dist/{components-CV5zuWa8.js → components-Dra_ac_5.js} +350 -71
  18. package/dist/components-Dra_ac_5.js.map +1 -0
  19. package/dist/composables/controlSchemaDoseDesign.d.ts +8 -1
  20. package/dist/composables/controlSchemaModel.d.ts +10 -1
  21. package/dist/composables/index.js +8 -8
  22. package/dist/composables/useAnalysisArtifacts.d.ts +9 -1
  23. package/dist/composables/useAppExperiment.d.ts +7 -0
  24. package/dist/composables/useBioTemplatePackWorkspace.d.ts +9 -1
  25. package/dist/composables/useControlWorkspace.d.ts +9 -1
  26. package/dist/composables/useGeneratedAnalysis.d.ts +9 -0
  27. package/dist/{composables-CW8NRXLB.js → composables-CRR_JxQD.js} +4 -4
  28. package/dist/{composables-CW8NRXLB.js.map → composables-CRR_JxQD.js.map} +1 -1
  29. package/dist/{experiment-utils-7Yw-h0tD.js → experiment-utils-CLnzPPq5.js} +170 -13
  30. package/dist/experiment-utils-CLnzPPq5.js.map +1 -0
  31. package/dist/index.js +13 -13
  32. package/dist/install.js +7 -7
  33. package/dist/styles.css +764 -610
  34. package/dist/templates/index.js +4 -4
  35. package/dist/{templates-CJRFiWUX.js → templates-Cr-48-u2.js} +2 -2
  36. package/dist/{templates-CJRFiWUX.js.map → templates-Cr-48-u2.js.map} +1 -1
  37. package/dist/types/components.d.ts +32 -0
  38. package/dist/types/index.d.ts +1 -1
  39. package/dist/{useControlSchema-BEAgLyyx.js → useControlSchema-BMVb2XIQ.js} +58 -10
  40. package/dist/useControlSchema-BMVb2XIQ.js.map +1 -0
  41. package/dist/{useDropdownState-Be-ug_ci.js → useDropdownState-DSb3BC3N.js} +2 -2
  42. package/dist/{useDropdownState-Be-ug_ci.js.map → useDropdownState-DSb3BC3N.js.map} +1 -1
  43. package/dist/{useExperimentSelector-DFg_iEHI.js → useExperimentSelector-D0pjj9WH.js} +3 -3
  44. package/dist/{useExperimentSelector-DFg_iEHI.js.map → useExperimentSelector-D0pjj9WH.js.map} +1 -1
  45. package/dist/{useFormBuilder-CR9pBApW.js → useFormBuilder-DG3HOFGA.js} +2 -2
  46. package/dist/{useFormBuilder-CR9pBApW.js.map → useFormBuilder-DG3HOFGA.js.map} +1 -1
  47. package/dist/{useProtocolTemplates-DXeIHMwV.js → useProtocolTemplates-BMt0LlXf.js} +21 -6
  48. package/dist/useProtocolTemplates-BMt0LlXf.js.map +1 -0
  49. package/package.json +1 -1
  50. package/src/__tests__/components/ActionMenu.test.ts +99 -0
  51. package/src/__tests__/components/BaseModal.test.ts +133 -0
  52. package/src/__tests__/components/SettingsModal.test.ts +9 -7
  53. package/src/__tests__/docs/extractDocsComponents.test.ts +15 -1
  54. package/src/__tests__/docs/extractDocsParsing.test.ts +22 -0
  55. package/src/__tests__/docs/frontendDocsCatalog.test.ts +80 -0
  56. package/src/components/ActionMenu.story.vue +26 -0
  57. package/src/components/ActionMenu.vue +212 -0
  58. package/src/components/AppSidebar.vue +2 -13
  59. package/src/components/ArtifactSelectorModal.vue +8 -1
  60. package/src/components/BaseModal.story.vue +107 -1
  61. package/src/components/BaseModal.vue +152 -4
  62. package/src/components/BioTemplateExperimentWorkspaceView.vue +8 -1
  63. package/src/components/BioTemplatePackWorkspaceView.vue +8 -1
  64. package/src/components/BioTemplatePresetWorkspaceView.vue +8 -1
  65. package/src/components/BioTemplateRenderer.vue +8 -1
  66. package/src/components/ComponentBindingRenderer.vue +8 -1
  67. package/src/components/ControlWorkspaceView.vue +8 -1
  68. package/src/components/DoseDesignWorkspaceView.vue +8 -1
  69. package/src/components/ExperimentSelectorModal.vue +8 -1
  70. package/src/components/FormBuilder.vue +5 -0
  71. package/src/components/GeneratedPluginView.vue +12 -2
  72. package/src/components/GeneratedResultRenderer.vue +12 -2
  73. package/src/components/JobsStatusTray.vue +8 -1
  74. package/src/components/PluginWorkspaceView.vue +10 -1
  75. package/src/components/SettingsModal.vue +102 -149
  76. package/src/components/index.ts +1 -0
  77. package/src/composables/controlSchemaDoseDesign.ts +8 -1
  78. package/src/composables/controlSchemaModel.ts +10 -1
  79. package/src/composables/useAnalysisArtifacts.ts +9 -1
  80. package/src/composables/useAppExperiment.ts +7 -0
  81. package/src/composables/useBioTemplatePackWorkspace.ts +9 -1
  82. package/src/composables/useControlWorkspace.ts +9 -1
  83. package/src/composables/useGeneratedAnalysis.ts +9 -0
  84. package/src/styles/components/action-menu.css +59 -0
  85. package/src/styles/components/icon-button.css +7 -3
  86. package/src/styles/components/modal.css +293 -0
  87. package/src/styles/components/settings-modal.css +3 -273
  88. package/src/styles/index.css +1 -0
  89. package/src/types/components.ts +38 -0
  90. package/src/types/index.ts +3 -0
  91. package/dist/ExperimentSelectorModal-DcQ38zua.js.map +0 -1
  92. package/dist/SettingsModal-Ccq2z-Ms.js.map +0 -1
  93. package/dist/components-CV5zuWa8.js.map +0 -1
  94. package/dist/experiment-utils-7Yw-h0tD.js.map +0 -1
  95. package/dist/useControlSchema-BEAgLyyx.js.map +0 -1
  96. package/dist/useProtocolTemplates-DXeIHMwV.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morscherlab/mint-sdk",
3
- "version": "1.1.5",
3
+ "version": "1.1.6",
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",
@@ -0,0 +1,99 @@
1
+ import { enableAutoUnmount, mount } from '@vue/test-utils'
2
+ import { afterEach, describe, expect, it } from 'vitest'
3
+ import ActionMenu from '../../components/ActionMenu.vue'
4
+
5
+ const items = [
6
+ { id: 'edit', label: 'Edit' },
7
+ { id: 'delete', label: 'Delete', danger: true },
8
+ ]
9
+
10
+ enableAutoUnmount(afterEach)
11
+
12
+ describe('ActionMenu', () => {
13
+ it('should expose menu semantics on the square trigger', () => {
14
+ const wrapper = mount(ActionMenu, {
15
+ props: { label: 'More user actions', items },
16
+ })
17
+
18
+ expect(wrapper.get('button').attributes('aria-haspopup')).toBe('menu')
19
+ })
20
+
21
+ it('should teleport the menu to the document body when opened', async () => {
22
+ const wrapper = mount(ActionMenu, {
23
+ attachTo: document.body,
24
+ props: { label: 'More user actions', items },
25
+ })
26
+
27
+ await wrapper.get('button').trigger('click')
28
+
29
+ expect(document.body.querySelector('.mint-action-menu__panel')).not.toBeNull()
30
+ })
31
+
32
+ it('should emit the selected action item when an item is clicked', async () => {
33
+ const wrapper = mount(ActionMenu, {
34
+ attachTo: document.body,
35
+ props: { label: 'More user actions', items },
36
+ })
37
+ await wrapper.get('button').trigger('click')
38
+
39
+ const menuItems = document.body.querySelectorAll<HTMLButtonElement>('.mint-action-menu__item')
40
+ menuItems[0]?.click()
41
+ await wrapper.vm.$nextTick()
42
+
43
+ expect(wrapper.emitted('select')).toEqual([[items[0]]])
44
+ })
45
+
46
+ it('should render link items as semantic anchors', async () => {
47
+ const wrapper = mount(ActionMenu, {
48
+ attachTo: document.body,
49
+ props: {
50
+ label: 'More plugin actions',
51
+ items: [{ id: 'docs', label: 'Documentation', href: '/docs' }],
52
+ },
53
+ })
54
+ await wrapper.get('button').trigger('click')
55
+
56
+ expect(document.body.querySelector('.mint-action-menu__item')?.tagName).toBe('A')
57
+ })
58
+
59
+ it('should mark destructive items with the danger treatment', async () => {
60
+ const wrapper = mount(ActionMenu, {
61
+ attachTo: document.body,
62
+ props: { label: 'More user actions', items },
63
+ })
64
+ await wrapper.get('button').trigger('click')
65
+
66
+ const menuItems = document.body.querySelectorAll('.mint-action-menu__item')
67
+
68
+ expect(menuItems[1]?.classList.contains('mint-action-menu__item--danger')).toBe(true)
69
+ })
70
+
71
+ it('should close an open menu when its scroll context moves', async () => {
72
+ const wrapper = mount(ActionMenu, {
73
+ attachTo: document.body,
74
+ props: { label: 'More user actions', items },
75
+ })
76
+ await wrapper.get('button').trigger('click')
77
+
78
+ window.dispatchEvent(new Event('scroll'))
79
+ await wrapper.vm.$nextTick()
80
+
81
+ expect(document.body.querySelector('.mint-action-menu__panel')).toBeNull()
82
+ })
83
+
84
+ it('should return focus to the trigger when Escape closes the menu', async () => {
85
+ const wrapper = mount(ActionMenu, {
86
+ attachTo: document.body,
87
+ props: { label: 'More user actions', items },
88
+ })
89
+ const trigger = wrapper.get<HTMLButtonElement>('button')
90
+ await trigger.trigger('click')
91
+
92
+ document.body.querySelector<HTMLElement>('.mint-action-menu__item')?.dispatchEvent(
93
+ new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
94
+ )
95
+ await wrapper.vm.$nextTick()
96
+
97
+ expect(document.activeElement).toBe(trigger.element)
98
+ })
99
+ })
@@ -0,0 +1,133 @@
1
+ import { mount } from '@vue/test-utils'
2
+ import { describe, expect, it } from 'vitest'
3
+ import BaseModal from '../../components/BaseModal.vue'
4
+ import type { ModalTab } from '../../types'
5
+
6
+ const stripTabs: ModalTab[] = [
7
+ { id: 'processing', label: 'Processing', meta: '6' },
8
+ { id: 'peaks', label: 'Peak detection' },
9
+ { id: 'correction', label: 'Correction', disabled: true },
10
+ { id: 'appearance', label: 'Appearance' },
11
+ ]
12
+
13
+ const railTabs: ModalTab[] = [
14
+ { id: 'profile', label: 'Profile', icon: 'M20 21a8 8 0 0 0-16 0M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8' },
15
+ { id: 'security', label: 'Security', meta: '2', description: 'Passkeys and institutional sign-in.' },
16
+ { id: 'appearance', label: 'Appearance' },
17
+ ]
18
+
19
+ function mountModal(props: Record<string, unknown> = {}, slots: Record<string, string> = {}) {
20
+ return mount(BaseModal, {
21
+ props: {
22
+ modelValue: true,
23
+ title: 'Settings',
24
+ ...props,
25
+ },
26
+ slots: {
27
+ default: '<p class="body-content">Body</p>',
28
+ ...slots,
29
+ },
30
+ global: {
31
+ stubs: {
32
+ teleport: true,
33
+ },
34
+ },
35
+ })
36
+ }
37
+
38
+ describe('BaseModal', () => {
39
+ describe('plain layout', () => {
40
+ it('should not render a tab strip when layout is omitted', () => {
41
+ const wrapper = mountModal()
42
+ expect(wrapper.find('.mint-modal__tabs').exists()).toBe(false)
43
+ })
44
+ })
45
+
46
+ describe('tabs layout', () => {
47
+ it('should render a shell-level tablist when layout is tabs', () => {
48
+ const wrapper = mountModal({ layout: 'tabs', tabs: stripTabs, activeTab: 'processing' })
49
+ expect(wrapper.find('.mint-modal__tabs[role="tablist"]').exists()).toBe(true)
50
+ })
51
+
52
+ it('should mark the active tab with aria-selected when a tab is active', () => {
53
+ const wrapper = mountModal({ layout: 'tabs', tabs: stripTabs, activeTab: 'peaks' })
54
+ const active = wrapper.findAll('.mint-modal__tab')[1]
55
+ expect(active.attributes('aria-selected')).toBe('true')
56
+ })
57
+
58
+ it('should render the tab meta when a tab defines one', () => {
59
+ const wrapper = mountModal({ layout: 'tabs', tabs: stripTabs, activeTab: 'processing' })
60
+ expect(wrapper.find('.mint-modal__tab-meta').text()).toBe('6')
61
+ })
62
+
63
+ it('should emit update:activeTab when another tab is clicked', async () => {
64
+ const wrapper = mountModal({ layout: 'tabs', tabs: stripTabs, activeTab: 'processing' })
65
+ await wrapper.findAll('.mint-modal__tab')[1].trigger('click')
66
+ expect(wrapper.emitted('update:activeTab')?.at(-1)).toEqual(['peaks'])
67
+ })
68
+
69
+ it('should not emit update:activeTab when a disabled tab is clicked', async () => {
70
+ const wrapper = mountModal({ layout: 'tabs', tabs: stripTabs, activeTab: 'processing' })
71
+ await wrapper.findAll('.mint-modal__tab')[2].trigger('click')
72
+ expect(wrapper.emitted('update:activeTab')).toBeUndefined()
73
+ })
74
+
75
+ it('should skip disabled tabs when moving with ArrowRight', async () => {
76
+ const wrapper = mountModal({ layout: 'tabs', tabs: stripTabs, activeTab: 'peaks' })
77
+ await wrapper.findAll('.mint-modal__tab')[1].trigger('keydown.right')
78
+ expect(wrapper.emitted('update:activeTab')?.at(-1)).toEqual(['appearance'])
79
+ })
80
+
81
+ it('should apply the fixed-height body modifier when layout is tabs', () => {
82
+ const wrapper = mountModal({ layout: 'tabs', tabs: stripTabs, activeTab: 'processing' })
83
+ expect(wrapper.find('.mint-modal__body').classes()).toContain('mint-modal__body--tabbed')
84
+ })
85
+ })
86
+
87
+ describe('rail layout', () => {
88
+ it('should render a vertical tablist when layout is rail', () => {
89
+ const wrapper = mountModal({ layout: 'rail', tabs: railTabs, activeTab: 'profile' })
90
+ expect(wrapper.find('.mint-modal__rail[aria-orientation="vertical"]').exists()).toBe(true)
91
+ })
92
+
93
+ it('should show the active tab label as the pane title', () => {
94
+ const wrapper = mountModal({ layout: 'rail', tabs: railTabs, activeTab: 'security' })
95
+ expect(wrapper.find('.mint-modal__pane-title').text()).toBe('Security')
96
+ })
97
+
98
+ it('should show the active tab description as the pane subtitle', () => {
99
+ const wrapper = mountModal({ layout: 'rail', tabs: railTabs, activeTab: 'security' })
100
+ expect(wrapper.find('.mint-modal__pane-subtitle').text()).toBe('Passkeys and institutional sign-in.')
101
+ })
102
+
103
+ it('should emit update:activeTab when a rail item is clicked', async () => {
104
+ const wrapper = mountModal({ layout: 'rail', tabs: railTabs, activeTab: 'profile' })
105
+ await wrapper.findAll('.mint-modal__rail-item')[1].trigger('click')
106
+ expect(wrapper.emitted('update:activeTab')?.at(-1)).toEqual(['security'])
107
+ })
108
+
109
+ it('should move to the next tab when ArrowDown is pressed on a rail item', async () => {
110
+ const wrapper = mountModal({ layout: 'rail', tabs: railTabs, activeTab: 'profile' })
111
+ await wrapper.findAll('.mint-modal__rail-item')[0].trigger('keydown.down')
112
+ expect(wrapper.emitted('update:activeTab')?.at(-1)).toEqual(['security'])
113
+ })
114
+
115
+ it('should render the rail-footer slot inside the rail', () => {
116
+ const wrapper = mountModal(
117
+ { layout: 'rail', tabs: railTabs, activeTab: 'profile' },
118
+ { 'rail-footer': '<button class="rail-logout">Log out</button>' },
119
+ )
120
+ expect(wrapper.find('.mint-modal__rail .rail-logout').exists()).toBe(true)
121
+ })
122
+
123
+ it('should not render the plain body when layout is rail', () => {
124
+ const wrapper = mountModal({ layout: 'rail', tabs: railTabs, activeTab: 'profile' })
125
+ expect(wrapper.find('.mint-modal__body').exists()).toBe(false)
126
+ })
127
+
128
+ it('should render the default slot inside the pane body when layout is rail', () => {
129
+ const wrapper = mountModal({ layout: 'rail', tabs: railTabs, activeTab: 'profile' })
130
+ expect(wrapper.find('.mint-modal__pane-body .body-content').exists()).toBe(true)
131
+ })
132
+ })
133
+ })
@@ -32,14 +32,16 @@ describe('SettingsModal', () => {
32
32
  'tab-Advanced': '<div class="advanced-tab">Advanced Content</div>',
33
33
  })
34
34
 
35
- const tabs = wrapper.findAll('.mint-settings-modal__tab')
35
+ const tabs = wrapper.findAll('.mint-modal__tab')
36
36
  expect(tabs.map(tab => tab.text())).toEqual(['General', 'Advanced'])
37
- expect(tabs[0].classes()).toContain('mint-settings-modal__tab--active')
37
+ expect(tabs[0].classes()).toContain('mint-modal__tab--active')
38
38
  expect(wrapper.find('.general-tab').exists()).toBe(true)
39
39
 
40
40
  await tabs[1].trigger('click')
41
41
 
42
- expect(tabs[1].classes()).toContain('mint-settings-modal__tab--active')
42
+ // Re-query: the teleport stub rebuilds the strip on re-render, so the
43
+ // pre-click wrappers point at detached nodes.
44
+ expect(wrapper.findAll('.mint-modal__tab')[1].classes()).toContain('mint-modal__tab--active')
43
45
  })
44
46
 
45
47
  it('syncs schema changes without remounting the modal', async () => {
@@ -78,7 +80,7 @@ describe('SettingsModal', () => {
78
80
  await wrapper.find('input').setValue('0.5')
79
81
  await wrapper.setProps({ schema: nextSchema })
80
82
 
81
- const tabs = wrapper.findAll('.mint-settings-modal__tab')
83
+ const tabs = wrapper.findAll('.mint-modal__tab')
82
84
  expect(tabs.map(tab => tab.text())).toEqual(['General', 'Advanced'])
83
85
  expect((wrapper.find('input').element as HTMLInputElement).value).toBe('0.5')
84
86
 
@@ -177,7 +179,7 @@ describe('SettingsModal', () => {
177
179
 
178
180
  const wrapper = mountSettingsModal({ controls })
179
181
 
180
- const tabs = wrapper.findAll('.mint-settings-modal__tab')
182
+ const tabs = wrapper.findAll('.mint-modal__tab')
181
183
  expect(tabs.map(tab => tab.text())).toEqual(['Parameters', 'Advanced'])
182
184
  expect((wrapper.find('input').element as HTMLInputElement).value).toBe('0.25')
183
185
 
@@ -355,7 +357,7 @@ describe('SettingsModal', () => {
355
357
 
356
358
  const adminWrapper = mountSettingsModal({ schema, userType: 'admin' })
357
359
 
358
- expect(adminWrapper.findAll('.mint-settings-modal__tab').map(tab => tab.text())).toEqual([
360
+ expect(adminWrapper.findAll('.mint-modal__tab').map(tab => tab.text())).toEqual([
359
361
  'General',
360
362
  'Admin',
361
363
  ])
@@ -363,7 +365,7 @@ describe('SettingsModal', () => {
363
365
  expect(adminWrapper.text()).toContain('Admin Secret')
364
366
  expect(adminWrapper.text()).not.toContain('User Note')
365
367
 
366
- await adminWrapper.findAll('.mint-settings-modal__tab')[1].trigger('click')
368
+ await adminWrapper.findAll('.mint-modal__tab')[1].trigger('click')
367
369
 
368
370
  expect(adminWrapper.text()).toContain('Admin Only')
369
371
  })
@@ -78,7 +78,14 @@ export { default as SpecialThing } from './SpecialThing.vue'
78
78
  mkdirSync(join(dir, 'src', 'components'), { recursive: true })
79
79
  writeFileSync(componentPath, `
80
80
  <script setup lang="ts">
81
- /** Fancy button docs. */
81
+ /**
82
+ * Fancy button docs.
83
+ *
84
+ * @example Recommended
85
+ * \`\`\`vue
86
+ * <FancyButton label="Save" />
87
+ * \`\`\`
88
+ */
82
89
  interface Props {
83
90
  /** Visible label. */
84
91
  label: string
@@ -114,6 +121,13 @@ defineSlots<{
114
121
  group: 'base',
115
122
  source: 'packages/sdk-frontend/src/components/FancyButton.vue',
116
123
  description: 'Fancy button docs.',
124
+ examples: [
125
+ {
126
+ title: 'Recommended',
127
+ language: 'vue',
128
+ code: '<FancyButton label="Save" />',
129
+ },
130
+ ],
117
131
  props: [
118
132
  {
119
133
  name: 'label',
@@ -4,6 +4,7 @@ import {
4
4
  cleanJsdocSummary,
5
5
  extractBalancedBraces,
6
6
  extractBalancedParens,
7
+ extractJsdocExamples,
7
8
  extractTrailingJsdoc,
8
9
  findMatchingClose,
9
10
  splitTopLevel,
@@ -66,4 +67,25 @@ describe('extract docs parsing helpers', () => {
66
67
 
67
68
  expect(extractTrailingJsdoc(source, source.indexOf('export function'))).toContain('Selected.')
68
69
  })
70
+
71
+ it('extracts structured examples from JSDoc fenced blocks', () => {
72
+ const comment = `
73
+ /**
74
+ * Component summary.
75
+ *
76
+ * @example Recommended
77
+ * \`\`\`vue
78
+ * <ExperimentSelectorModal v-model="open" />
79
+ * \`\`\`
80
+ */
81
+ `
82
+
83
+ expect(extractJsdocExamples(comment)).toEqual([
84
+ {
85
+ title: 'Recommended',
86
+ language: 'vue',
87
+ code: '<ExperimentSelectorModal v-model="open" />',
88
+ },
89
+ ])
90
+ })
69
91
  })
@@ -3,6 +3,13 @@ import { existsSync, readFileSync } from 'node:fs'
3
3
  import { dirname, join, resolve } from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
  import { describe, expect, it } from 'vitest'
6
+ import { compile } from 'vue'
7
+ import {
8
+ DiagnosticCategory,
9
+ ModuleKind,
10
+ ScriptTarget,
11
+ transpileModule,
12
+ } from 'typescript'
6
13
 
7
14
  interface FrontendManifest {
8
15
  categories: {
@@ -10,10 +17,13 @@ interface FrontendManifest {
10
17
  name: string
11
18
  deprecated?: boolean
12
19
  replacement?: string
20
+ examples?: Array<{ title: string; language: string; code: string }>
21
+ props?: Array<{ name: string }>
13
22
  }>
14
23
  composables: Array<{
15
24
  name: string
16
25
  members?: Array<{ name: string }>
26
+ examples?: Array<{ title: string; language: string; code: string }>
17
27
  }>
18
28
  templateExports: Array<{ name: string }>
19
29
  templatePacks: Array<{ name: string }>
@@ -23,6 +33,18 @@ interface FrontendManifest {
23
33
 
24
34
  const TEST_DIR = dirname(fileURLToPath(import.meta.url))
25
35
  const SDK_ROOT = resolve(TEST_DIR, '../../..')
36
+ const CORE_USAGE_NAMES = [
37
+ 'ArtifactSelectorModal',
38
+ 'ControlWorkspaceView',
39
+ 'ExperimentSelectorModal',
40
+ 'GeneratedPluginView',
41
+ 'GeneratedResultRenderer',
42
+ 'JobsStatusTray',
43
+ 'PluginWorkspaceView',
44
+ 'useAnalysisArtifacts',
45
+ 'useAppExperiment',
46
+ 'usePluginJobs',
47
+ ]
26
48
 
27
49
  function extractFrontendManifest(): FrontendManifest {
28
50
  const output = execFileSync('bun', ['run', 'scripts/extract-docs.ts'], {
@@ -191,6 +213,64 @@ describe('frontend docs catalog coverage', () => {
191
213
  expect(missingFromManifest(documentedComponents, publicComponents)).toEqual([])
192
214
  })
193
215
 
216
+ it('keeps source-owned examples for the core frontend documentation contract', () => {
217
+ const manifest = extractFrontendManifest()
218
+ const documented = [
219
+ ...manifest.categories.components,
220
+ ...manifest.categories.composables,
221
+ ]
222
+ const byName = new Map(documented.map(item => [item.name, item]))
223
+ expect(CORE_USAGE_NAMES.filter(name => !byName.get(name)?.examples?.length)).toEqual([])
224
+ })
225
+
226
+ it('keeps core frontend examples syntactically valid', () => {
227
+ const manifest = extractFrontendManifest()
228
+ const documented = [
229
+ ...manifest.categories.components,
230
+ ...manifest.categories.composables,
231
+ ]
232
+ const byName = new Map(documented.map(item => [item.name, item]))
233
+ const invalid: string[] = []
234
+
235
+ for (const name of CORE_USAGE_NAMES) {
236
+ for (const example of byName.get(name)?.examples ?? []) {
237
+ if (example.language === 'vue') {
238
+ try {
239
+ compile(example.code)
240
+ } catch {
241
+ invalid.push(name)
242
+ }
243
+ continue
244
+ }
245
+ const result = transpileModule(example.code, {
246
+ compilerOptions: {
247
+ module: ModuleKind.ESNext,
248
+ target: ScriptTarget.ESNext,
249
+ },
250
+ reportDiagnostics: true,
251
+ })
252
+ if (result.diagnostics?.some(diagnostic => diagnostic.category === DiagnosticCategory.Error)) {
253
+ invalid.push(name)
254
+ }
255
+ }
256
+ }
257
+
258
+ expect(invalid).toEqual([])
259
+ })
260
+
261
+ it('documents required props for generated high-level components', () => {
262
+ const manifest = extractFrontendManifest()
263
+ const byName = new Map(manifest.categories.components.map(item => [item.name, item]))
264
+
265
+ expect({
266
+ GeneratedPluginView: byName.get('GeneratedPluginView')?.props?.map(prop => prop.name),
267
+ GeneratedResultRenderer: byName.get('GeneratedResultRenderer')?.props?.map(prop => prop.name),
268
+ }).toEqual({
269
+ GeneratedPluginView: ['baseUrl', 'showStandaloneLabel', 'transport'],
270
+ GeneratedResultRenderer: ['result'],
271
+ })
272
+ })
273
+
194
274
  it('re-exports every public component from the root SDK entrypoint', () => {
195
275
  const publicComponents = parseComponentExports()
196
276
  const rootComponents = parseRootComponentExports()
@@ -0,0 +1,26 @@
1
+ <script setup lang="ts">
2
+ import ActionMenu from './ActionMenu.vue'
3
+ import type { ActionMenuItem } from '../types'
4
+
5
+ const items: ActionMenuItem[] = [
6
+ { id: 'edit', label: 'Edit' },
7
+ { id: 'docs', label: 'Documentation ↗', href: 'https://example.com/docs', target: '_blank' },
8
+ { id: 'delete', label: 'Delete', danger: true },
9
+ ]
10
+ </script>
11
+
12
+ <template>
13
+ <Story title="Action/ActionMenu">
14
+ <Variant title="Default">
15
+ <div style="min-height: 14rem; display: flex; justify-content: center; padding: 2rem;">
16
+ <ActionMenu label="More actions" :items="items" />
17
+ </div>
18
+ </Variant>
19
+
20
+ <Variant title="Disabled">
21
+ <div style="display: flex; justify-content: center; padding: 2rem;">
22
+ <ActionMenu label="More actions" :items="items" disabled />
23
+ </div>
24
+ </Variant>
25
+ </Story>
26
+ </template>