@morscherlab/mint-sdk 1.2.0 → 1.2.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 (152) hide show
  1. package/dist/{ExperimentSelectorModal-DCXmwKNS.js → ExperimentSelectorModal-DHE9k8TP.js} +3 -3
  2. package/dist/{ExperimentSelectorModal-DCXmwKNS.js.map → ExperimentSelectorModal-DHE9k8TP.js.map} +1 -1
  3. package/dist/{SettingsModal-BYR20I4c.js → SettingsModal-DELMkUFR.js} +39 -16
  4. package/dist/SettingsModal-DELMkUFR.js.map +1 -0
  5. package/dist/{Skeleton-BAF3CKY7.js → Skeleton-jhF9wUkU.js} +17 -7
  6. package/dist/Skeleton-jhF9wUkU.js.map +1 -0
  7. package/dist/__tests__/components/ChartContainer.test.d.ts +1 -0
  8. package/dist/__tests__/components/ChemicalFormula.test.d.ts +1 -0
  9. package/dist/__tests__/components/FormField.test.d.ts +1 -0
  10. package/dist/__tests__/components/LayoutResizeHandle.test.d.ts +1 -0
  11. package/dist/__tests__/components/NumberInput.test.d.ts +1 -0
  12. package/dist/__tests__/components/SearchableSelect.test.d.ts +1 -0
  13. package/dist/__tests__/components/WellPlate.decoration.test.d.ts +1 -0
  14. package/dist/__tests__/composables/localFiles.test.d.ts +1 -0
  15. package/dist/__tests__/composables/useManualLayoutResize.test.d.ts +1 -0
  16. package/dist/__tests__/composables/usePickerSelection.test.d.ts +1 -0
  17. package/dist/__tests__/styles/tokenOnlyColors.test.d.ts +1 -0
  18. package/dist/components/AdductText.vue.d.ts +17 -0
  19. package/dist/components/AppSidebar.vue.d.ts +7 -7
  20. package/dist/components/BasePill.vue.d.ts +3 -0
  21. package/dist/components/BaseTabs.vue.d.ts +1 -4
  22. package/dist/components/ChartContainer.vue.d.ts +19 -5
  23. package/dist/components/ChemicalFormula.vue.d.ts +28 -2
  24. package/dist/components/DataFrame.vue.d.ts +1 -1
  25. package/dist/components/FileUploader.vue.d.ts +3 -0
  26. package/dist/components/FormField.vue.d.ts +8 -1
  27. package/dist/components/LayoutResizeHandle.vue.d.ts +23 -0
  28. package/dist/components/PlotlyChart.vue.d.ts +34 -6
  29. package/dist/components/PluginWorkspaceView.props.d.ts +3 -3
  30. package/dist/components/SearchableSelect.vue.d.ts +57 -0
  31. package/dist/components/WellPlate.vue.d.ts +5 -1
  32. package/dist/components/index.d.ts +3 -1
  33. package/dist/components/index.js +6 -6
  34. package/dist/{components-DIum5hkx.js → components-CxQVQCpP.js} +1215 -546
  35. package/dist/components-CxQVQCpP.js.map +1 -0
  36. package/dist/composables/index.d.ts +1 -0
  37. package/dist/composables/index.js +8 -8
  38. package/dist/composables/pluginWorkspaceModel.d.ts +0 -3
  39. package/dist/composables/useManualLayoutResize.d.ts +56 -0
  40. package/dist/composables/useToast.d.ts +22 -1
  41. package/dist/{composables-mFXsTA4g.js → composables-DAqOU8Aq.js} +97 -8
  42. package/dist/composables-DAqOU8Aq.js.map +1 -0
  43. package/dist/index.js +11 -11
  44. package/dist/install.js +2 -2
  45. package/dist/styles.css +855 -320
  46. package/dist/templates/index.js +2 -2
  47. package/dist/{templates-CmGfmBRA.js → templates-NuaNHohp.js} +2 -2
  48. package/dist/{templates-CmGfmBRA.js.map → templates-NuaNHohp.js.map} +1 -1
  49. package/dist/types/componentLabTypes.d.ts +3 -0
  50. package/dist/types/components.d.ts +26 -1
  51. package/dist/types/form-builder.d.ts +2 -0
  52. package/dist/types/generated-ui.d.ts +2 -0
  53. package/dist/types/index.d.ts +1 -1
  54. package/dist/{useControlSchema-DldpxNOP.js → useControlSchema-vLiRPUmj.js} +181 -71
  55. package/dist/useControlSchema-vLiRPUmj.js.map +1 -0
  56. package/dist/{useExperimentSelector-CEcIaueJ.js → useExperimentSelector-G_CAOFTX.js} +2 -2
  57. package/dist/{useExperimentSelector-CEcIaueJ.js.map → useExperimentSelector-G_CAOFTX.js.map} +1 -1
  58. package/dist/{useFormBuilder-z5L_dUIy.js → useFormBuilder-D37ajgS8.js} +2 -2
  59. package/dist/{useFormBuilder-z5L_dUIy.js.map → useFormBuilder-D37ajgS8.js.map} +1 -1
  60. package/dist/{validation-uwoJGEb6.js → validation-ChMyixAf.js} +21 -9
  61. package/dist/validation-ChMyixAf.js.map +1 -0
  62. package/package.json +1 -1
  63. package/src/__tests__/components/AppSidebar.test.ts +84 -94
  64. package/src/__tests__/components/AppToastContainer.test.ts +55 -1
  65. package/src/__tests__/components/BioTemplatePresetWorkspaceView.test.ts +0 -21
  66. package/src/__tests__/components/ChartContainer.test.ts +24 -0
  67. package/src/__tests__/components/ChemicalFormula.test.ts +27 -0
  68. package/src/__tests__/components/ControlWorkspaceView.test.ts +0 -2
  69. package/src/__tests__/components/DoseDesignWorkspaceView.test.ts +0 -2
  70. package/src/__tests__/components/FormBuilder.test.ts +26 -0
  71. package/src/__tests__/components/FormField.test.ts +26 -0
  72. package/src/__tests__/components/GeneratedPathInput.test.ts +18 -0
  73. package/src/__tests__/components/LayoutResizeHandle.test.ts +19 -0
  74. package/src/__tests__/components/NumberInput.test.ts +149 -0
  75. package/src/__tests__/components/PlotlyChart.test.ts +48 -0
  76. package/src/__tests__/components/PluginWorkspaceView.internal.test.ts +1 -2
  77. package/src/__tests__/components/PluginWorkspaceView.test.ts +1 -2
  78. package/src/__tests__/components/RackEditor.test.ts +9 -0
  79. package/src/__tests__/components/SearchableSelect.test.ts +61 -0
  80. package/src/__tests__/components/WellPlate.decoration.test.ts +28 -0
  81. package/src/__tests__/composables/localFiles.test.ts +57 -0
  82. package/src/__tests__/composables/useManualLayoutResize.test.ts +21 -0
  83. package/src/__tests__/composables/usePickerSelection.test.ts +98 -0
  84. package/src/__tests__/generatedUi.test.ts +111 -0
  85. package/src/__tests__/styles/tokenOnlyColors.test.ts +16 -0
  86. package/src/components/AdductText.story.vue +15 -0
  87. package/src/components/AdductText.vue +40 -0
  88. package/src/components/AppLayout.story.vue +1 -1
  89. package/src/components/AppSidebar.story.vue +7 -10
  90. package/src/components/AppSidebar.vue +178 -58
  91. package/src/components/AppToastContainer.vue +65 -23
  92. package/src/components/BasePill.story.vue +2 -0
  93. package/src/components/BasePill.vue +4 -0
  94. package/src/components/BaseTabs.story.vue +0 -42
  95. package/src/components/BaseTabs.vue +3 -6
  96. package/src/components/ChartContainer.story.vue +21 -0
  97. package/src/components/ChartContainer.vue +27 -6
  98. package/src/components/ChemicalFormula.story.vue +7 -0
  99. package/src/components/ChemicalFormula.vue +40 -11
  100. package/src/components/FileUploader.vue +10 -1
  101. package/src/components/FormField.vue +10 -3
  102. package/src/components/GeneratedPathInput.vue +11 -7
  103. package/src/components/LayoutResizeHandle.story.vue +26 -0
  104. package/src/components/LayoutResizeHandle.vue +62 -0
  105. package/src/components/NumberInput.story.vue +32 -13
  106. package/src/components/NumberInput.vue +145 -56
  107. package/src/components/PlotlyChart.vue +77 -11
  108. package/src/components/PluginWorkspaceView.props.ts +3 -3
  109. package/src/components/RackEditor.vue +38 -32
  110. package/src/components/SearchableSelect.story.vue +24 -0
  111. package/src/components/SearchableSelect.vue +223 -0
  112. package/src/components/WellPlate.story.vue +16 -0
  113. package/src/components/WellPlate.vue +17 -8
  114. package/src/components/index.ts +3 -1
  115. package/src/components/internal/FormFieldRendererInternal.vue +17 -0
  116. package/src/components/internal/WorkspaceSidebarInternal.vue +1 -2
  117. package/src/composables/filePicker/localFiles.ts +21 -14
  118. package/src/composables/filePicker/usePickerSelection.ts +15 -4
  119. package/src/composables/index.ts +10 -0
  120. package/src/composables/pluginWorkspaceModel.ts +0 -3
  121. package/src/composables/useManualLayoutResize.ts +165 -0
  122. package/src/composables/usePluginWorkspace.ts +1 -2
  123. package/src/composables/useToast.ts +21 -5
  124. package/src/generated/schema.ts +58 -18
  125. package/src/styles/components/adduct-text.css +45 -0
  126. package/src/styles/components/app-sidebar.css +282 -121
  127. package/src/styles/components/chart-container.css +92 -0
  128. package/src/styles/components/chemical-formula.css +34 -4
  129. package/src/styles/components/form-builder.css +1 -1
  130. package/src/styles/components/form-field.css +28 -0
  131. package/src/styles/components/layout-resize-handle.css +60 -0
  132. package/src/styles/components/number-input.css +75 -91
  133. package/src/styles/components/pill.css +49 -51
  134. package/src/styles/components/rack-editor.css +31 -9
  135. package/src/styles/components/searchable-select.css +63 -0
  136. package/src/styles/components/segmented-control.css +16 -11
  137. package/src/styles/components/tabs.css +18 -34
  138. package/src/styles/components/toast.css +136 -27
  139. package/src/styles/components/well-plate.css +9 -0
  140. package/src/styles/index.css +3 -0
  141. package/src/styles/variables.css +25 -5
  142. package/src/types/componentLabTypes.ts +6 -0
  143. package/src/types/components.ts +30 -0
  144. package/src/types/form-builder.ts +2 -0
  145. package/src/types/generated-ui.ts +2 -0
  146. package/src/types/index.ts +5 -0
  147. package/dist/SettingsModal-BYR20I4c.js.map +0 -1
  148. package/dist/Skeleton-BAF3CKY7.js.map +0 -1
  149. package/dist/components-DIum5hkx.js.map +0 -1
  150. package/dist/composables-mFXsTA4g.js.map +0 -1
  151. package/dist/useControlSchema-DldpxNOP.js.map +0 -1
  152. package/dist/validation-uwoJGEb6.js.map +0 -1
@@ -6,6 +6,8 @@ import {
6
6
  normalizeGeneratedJobInput,
7
7
  } from '../generated/schema'
8
8
  import type { GeneratedJobDefinition } from '../types/generated-ui'
9
+ import { validateFieldValue } from '../composables/useFormValidation'
10
+ import { buildRules } from '../composables/formBuilderSchema'
9
11
 
10
12
  const definition: GeneratedJobDefinition = {
11
13
  id: 'summarize',
@@ -30,6 +32,115 @@ const definition: GeneratedJobDefinition = {
30
32
  }
31
33
 
32
34
  describe('generated UI schema adapter', () => {
35
+ it('allows present empty strings, arrays, nullable values and false', () => {
36
+ const requiredDefinition: GeneratedJobDefinition = {
37
+ ...definition,
38
+ input_schema: {
39
+ properties: {
40
+ nullable: { anyOf: [{ type: 'string' }, { type: 'null' }] },
41
+ text: { type: 'string' },
42
+ labels: { type: 'array', items: { type: 'string' } },
43
+ enabled: { type: 'boolean' },
44
+ nonempty: { type: 'string', minLength: 1 },
45
+ },
46
+ required: ['nullable', 'text', 'labels', 'enabled', 'nonempty'],
47
+ },
48
+ }
49
+ const fields = generatedJobFormSchema(requiredDefinition).sections[0]!.fields
50
+ const rules = buildRules(fields)
51
+ const values = { nullable: null, text: '', labels: [], enabled: false, nonempty: 'valid' }
52
+ expect(fields.map(field => validateFieldValue(values[field.name as keyof typeof values], rules[field.name], values)))
53
+ .toEqual([null, null, null, null, null])
54
+ expect(generatedJobDefaults(requiredDefinition)).toEqual({ nullable: null })
55
+ expect(validateFieldValue('', rules.nonempty, {})).not.toBeNull()
56
+ expect(normalizeGeneratedJobInput(requiredDefinition, values)).toEqual(values)
57
+ })
58
+
59
+ it('supplies fixed literal values and resolves array enums and numeric items', () => {
60
+ const arrayDefinition: GeneratedJobDefinition = {
61
+ ...definition,
62
+ input_schema: {
63
+ $defs: { Choice: { type: 'integer', enum: [1, 2] } },
64
+ properties: {
65
+ kind: { type: 'string', const: 'fixed' },
66
+ choices: { type: 'array', items: { $ref: '#/$defs/Choice' } },
67
+ counts: { type: 'array', items: { type: 'integer' } },
68
+ scales: { type: 'array', items: { type: 'number' } },
69
+ labels: { type: 'array', items: { type: 'string' } },
70
+ flags: { type: 'array', items: { type: 'boolean' } },
71
+ },
72
+ },
73
+ }
74
+ expect(generatedJobDefaults(arrayDefinition)).toEqual({ kind: 'fixed' })
75
+ expect(generatedJobFormSchema(arrayDefinition).sections[0]!.fields).toMatchObject([
76
+ { name: 'kind', defaultValue: 'fixed', disabled: true },
77
+ { name: 'choices', type: 'multiselect', props: { options: [1, 2] } },
78
+ { name: 'counts', type: 'tags' },
79
+ { name: 'scales', type: 'tags' },
80
+ { name: 'labels', type: 'tags', props: { allowDuplicates: true } },
81
+ { name: 'flags', type: 'tags' },
82
+ ])
83
+ expect(normalizeGeneratedJobInput(arrayDefinition, {
84
+ counts: ['1', '2'], scales: ['0.5', '1.5'], choices: [1, 2], labels: ['1', '1'],
85
+ })).toEqual({ counts: [1, 2], scales: [0.5, 1.5], choices: [1, 2], labels: ['1', '1'] })
86
+ expect(normalizeGeneratedJobInput(arrayDefinition, { counts: ['bad', '1.5'] }))
87
+ .toEqual({ counts: ['bad', 1.5] })
88
+ expect(normalizeGeneratedJobInput(arrayDefinition, { flags: ['true', 'false', true, 'bad'] }))
89
+ .toEqual({ flags: [true, false, true, 'bad'] })
90
+ })
91
+
92
+ it('applies integer bounds and numeric steps to the existing controls', () => {
93
+ const boundedDefinition: GeneratedJobDefinition = {
94
+ ...definition,
95
+ input_schema: { properties: {
96
+ count: { type: 'integer', exclusiveMinimum: 0, exclusiveMaximum: 10, multipleOf: 2 },
97
+ fraction: { type: 'number', minimum: 0, maximum: 1, multipleOf: 0.1 },
98
+ } },
99
+ }
100
+ const fields = generatedJobFormSchema(boundedDefinition).sections[0]!.fields
101
+ const rules = buildRules(fields)
102
+ expect(fields).toMatchObject([
103
+ { validation: { min: 2, max: 8 }, props: { min: 2, max: 8, step: 2 } },
104
+ { validation: { min: 0, max: 1 }, props: { min: 0, max: 1, step: 0.1 } },
105
+ ])
106
+ expect(validateFieldValue(0, rules.count, {})).not.toBeNull()
107
+ expect(validateFieldValue(10, rules.count, {})).not.toBeNull()
108
+ })
109
+
110
+ it('preserves field labels and hints outside nullable schemas and enum references', () => {
111
+ const annotatedDefinition: GeneratedJobDefinition = {
112
+ ...definition,
113
+ input_schema: {
114
+ type: 'object',
115
+ $defs: { Method: { type: 'string', enum: ['fast', 'robust'], title: 'Method' } },
116
+ properties: {
117
+ count: {
118
+ anyOf: [{ type: 'integer', minimum: 1 }, { type: 'null' }],
119
+ title: 'Optional sample count',
120
+ description: 'Leave empty to use all samples',
121
+ default: null,
122
+ },
123
+ method: {
124
+ $ref: '#/$defs/Method',
125
+ title: 'Analysis method',
126
+ description: 'Choose the analysis method',
127
+ },
128
+ },
129
+ },
130
+ }
131
+
132
+ expect(generatedJobFormSchema(annotatedDefinition).sections[0]?.fields).toMatchObject([
133
+ {
134
+ name: 'count', type: 'number', label: 'Optional sample count',
135
+ hint: 'Leave empty to use all samples', defaultValue: null, validation: { min: 1 },
136
+ },
137
+ {
138
+ name: 'method', type: 'select', label: 'Analysis method',
139
+ hint: 'Choose the analysis method', props: { options: ['fast', 'robust'] },
140
+ },
141
+ ])
142
+ })
143
+
33
144
  it('maps the supported JSON Schema subset to standard FormBuilder fields', () => {
34
145
  expect(generatedJobFormSchema(definition).sections[0]?.fields).toMatchObject([
35
146
  { name: 'threshold', type: 'number', validation: { required: true, min: 0, max: 10 } },
@@ -0,0 +1,16 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { dirname, join, resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { describe, expect, it } from 'vitest'
5
+
6
+ const here = dirname(fileURLToPath(import.meta.url))
7
+ const componentsDir = resolve(here, '../../styles/components')
8
+
9
+ // Every color in these sheets must resolve from a token so dark mode remaps it.
10
+ const TOKEN_ONLY = ['pill.css', 'tabs.css', 'segmented-control.css', 'toast.css']
11
+
12
+ describe('token-only component stylesheets', () => {
13
+ it.each(TOKEN_ONLY)('%s has no rgba() literal', (file) => {
14
+ expect(readFileSync(join(componentsDir, file), 'utf-8')).not.toMatch(/rgba?\(/)
15
+ })
16
+ })
@@ -0,0 +1,15 @@
1
+ <script setup lang="ts">
2
+ import AdductText from './AdductText.vue'
3
+ </script>
4
+
5
+ <template>
6
+ <Story title="Data Display/AdductText">
7
+ <Variant title="Inline and badge">
8
+ <div style="display: flex; align-items: center; gap: 1rem; padding: 2rem; color: var(--text-primary);">
9
+ <AdductText value="[M+H]+" />
10
+ <AdductText value="[M+Na]+" variant="badge" />
11
+ <AdductText />
12
+ </div>
13
+ </Variant>
14
+ </Story>
15
+ </template>
@@ -0,0 +1,40 @@
1
+ <script setup lang="ts">
2
+ /** Compact, overflow-safe display for mass-spectrometry adduct notation. */
3
+ import { computed } from 'vue'
4
+
5
+ interface Props {
6
+ value?: string | null
7
+ emptyText?: string
8
+ variant?: 'inline' | 'badge'
9
+ tone?: 'default' | 'on-primary' | 'muted'
10
+ size?: 'xs' | 'inherit'
11
+ font?: 'mono' | 'sans'
12
+ }
13
+
14
+ const props = withDefaults(defineProps<Props>(), {
15
+ value: null,
16
+ emptyText: '-',
17
+ variant: 'inline',
18
+ tone: 'default',
19
+ size: 'xs',
20
+ font: 'mono',
21
+ })
22
+
23
+ const value = computed(() => props.value?.trim() ?? '')
24
+ </script>
25
+
26
+ <template>
27
+ <span
28
+ :class="[
29
+ 'mint-adduct-text',
30
+ `mint-adduct-text--${variant}`,
31
+ `mint-adduct-text--${tone}`,
32
+ `mint-adduct-text--${size}`,
33
+ `mint-adduct-text--${font}`,
34
+ { 'mint-adduct-text--empty': !value },
35
+ ]"
36
+ :title="value || undefined"
37
+ >
38
+ {{ value || emptyText }}
39
+ </span>
40
+ </template>
@@ -229,7 +229,7 @@ const logScale = ref(false)
229
229
  </template>
230
230
  <template #sidebar>
231
231
  <AppSidebar
232
- variant="analysis"
232
+ :floating="false"
233
233
  title="Feature Filters"
234
234
  subtitle="12 active samples"
235
235
  :badge="2"
@@ -16,7 +16,7 @@ import { useBioTemplateWorkspace } from '../composables/useBioTemplateWorkspace'
16
16
  import { createWellPlateScreenCollection } from '../templates'
17
17
  import type { SidebarToolSection } from '../types'
18
18
 
19
- type SidebarVariant = 'analysis' | 'default'
19
+ type SidebarDensity = 'compact' | 'comfortable'
20
20
 
21
21
  /* --- Icon paths (Lucide-style) --- */
22
22
  const icons = {
@@ -256,7 +256,7 @@ function handleToggle(id: string, value: boolean) {
256
256
  function initSimpleToolkit() {
257
257
  return {
258
258
  activeView: 'analysis',
259
- variant: 'analysis' as SidebarVariant,
259
+ density: 'compact' as SidebarDensity,
260
260
  floating: false,
261
261
  width: '20rem',
262
262
  side: 'left' as const,
@@ -351,7 +351,6 @@ function initSimpleToolkit() {
351
351
  <Variant title="Schema Driven Controls">
352
352
  <div style="padding: 2rem; height: 520px; position: relative; background: var(--bg-primary, #f1f5f9);">
353
353
  <AppSidebar
354
- variant="analysis"
355
354
  title="Peak Picking"
356
355
  subtitle="Current experiment"
357
356
  :controls="schemaControls"
@@ -365,7 +364,6 @@ function initSimpleToolkit() {
365
364
  <div style="padding: 2rem; height: 560px; position: relative; background: var(--bg-primary, #f1f5f9);">
366
365
  <AppSidebar
367
366
  v-model="modelDrivenValues"
368
- variant="analysis"
369
367
  title="Peak Picking"
370
368
  subtitle="Current experiment"
371
369
  :model="modelDrivenSidebarModel"
@@ -376,7 +374,6 @@ function initSimpleToolkit() {
376
374
  <Variant title="Route Owned Shell">
377
375
  <div style="padding: 2rem; height: 560px; display: flex; gap: 1rem; background: var(--bg-primary, #f1f5f9);">
378
376
  <AppSidebar
379
- variant="analysis"
380
377
  title="Sequence"
381
378
  subtitle="Acquisition run"
382
379
  content-id="seqgen-sidebar"
@@ -466,7 +463,7 @@ function initSimpleToolkit() {
466
463
  </div>
467
464
  <AppSidebar
468
465
  :panels="simplePanels"
469
- :variant="state.variant"
466
+ :density="state.density"
470
467
  :active-view="state.activeView"
471
468
  :floating="state.floating"
472
469
  :width="state.width"
@@ -531,11 +528,11 @@ function initSimpleToolkit() {
531
528
  :options="['analysis', 'results', 'settings'].map(v => ({ label: v, value: v }))"
532
529
  />
533
530
  <HstSelect
534
- v-model="state.variant"
535
- title="Variant"
531
+ v-model="state.density"
532
+ title="Density"
536
533
  :options="[
537
- { label: 'Analysis', value: 'analysis' },
538
- { label: 'Default', value: 'default' },
534
+ { label: 'Compact', value: 'compact' },
535
+ { label: 'Comfortable', value: 'comfortable' },
539
536
  ]"
540
537
  />
541
538
  <HstCheckbox v-model="state.floating" title="Floating" />
@@ -3,8 +3,9 @@
3
3
  * AppSidebar - Context-sensitive toolkit panel
4
4
  *
5
5
  * Shows tool sections relevant to the active view. Sections are defined via
6
- * the `panels` config and rendered as CollapsibleCards. Controls can be
7
- * provided through named slots or auto-rendered from FormBuilder schemas.
6
+ * the `panels` config and rendered as flat groups (header row + hairline,
7
+ * no nested card). Controls can be provided through named slots or
8
+ * auto-rendered from FormBuilder schemas.
8
9
  *
9
10
  * When activeView is omitted, the first non-empty panel view is selected.
10
11
  * When no view has matching panels, the sidebar hides entirely.
@@ -15,7 +16,7 @@
15
16
  * ```
16
17
  */
17
18
  import { computed, ref, useSlots, type Slots } from 'vue'
18
- import type { PillNavItem, SidebarToolSection } from '../types'
19
+ import type { PillNavItem, SidebarToolSection, SidebarToolSectionAction } from '../types'
19
20
  import type { FormEnhancements, FormSchema } from '../types/form-builder'
20
21
  import {
21
22
  controlsToSectionFormSchemas,
@@ -28,7 +29,6 @@ import {
28
29
  type ControlSchema,
29
30
  type ControlWorkspaceOptions,
30
31
  } from '../composables/useControlSchema'
31
- import CollapsibleCard from './CollapsibleCard.vue'
32
32
  import FormBuilder from './FormBuilder.vue'
33
33
 
34
34
  interface Props {
@@ -38,17 +38,17 @@ interface Props {
38
38
  subtitle?: string
39
39
  /** Optional compact badge/count rendered in the chrome header. */
40
40
  badge?: string | number
41
- /** Visual preset for common plugin sidebars. `analysis` preserves the LEAF-style MINT analysis sidebar design language. */
42
- variant?: 'default' | 'analysis'
43
41
  /** Map of view IDs to their tool sections */
44
42
  panels?: Record<string, SidebarToolSection[]>
45
43
  /** Which view's panels to display. Defaults to the first non-empty panel view. */
46
44
  activeView?: string
47
- /** Floating variant with absolute positioning. Defaults to false for analysis variant. */
45
+ /** Floating shell with absolute positioning. Prefer letting `AppLayout` own this; it stays for standalone use. */
48
46
  floating?: boolean
49
- /** Compact layout: smaller headers, tighter spacing, no icon backgrounds */
47
+ /** Section density. `compact` (default) is the 36px header row; `comfortable` restores the roomier spacing. */
48
+ density?: 'compact' | 'comfortable'
49
+ /** @deprecated Compact is now the default. Pass `density="comfortable"` for the roomier layout. */
50
50
  dense?: boolean
51
- /** Width when visible. Defaults to 20rem for analysis variant, otherwise 280px. */
51
+ /** Width when visible. */
52
52
  width?: string
53
53
  /** Position sidebar on left or right side */
54
54
  side?: 'left' | 'right'
@@ -88,7 +88,7 @@ interface Props {
88
88
  formReadonly?: boolean
89
89
  /** Size passed to auto-rendered section forms */
90
90
  formSize?: 'sm' | 'md' | 'lg'
91
- /** Show a built-in collapse/expand button in the sidebar chrome. Defaults to true for analysis variant. */
91
+ /** Show a built-in collapse/expand button in the sidebar chrome. */
92
92
  collapsible?: boolean
93
93
  /** Controlled collapsed state. */
94
94
  collapsed?: boolean
@@ -106,12 +106,12 @@ const props = withDefaults(defineProps<Props>(), {
106
106
  title: undefined,
107
107
  subtitle: undefined,
108
108
  badge: undefined,
109
- variant: 'default',
110
109
  panels: () => ({}),
111
110
  activeView: '',
112
- floating: undefined,
113
- dense: false,
114
- width: undefined,
111
+ floating: true,
112
+ density: undefined,
113
+ dense: undefined,
114
+ width: '280px',
115
115
  side: 'left',
116
116
  toggleState: () => ({}),
117
117
  forms: () => ({}),
@@ -129,7 +129,7 @@ const props = withDefaults(defineProps<Props>(), {
129
129
  formDisabled: false,
130
130
  formReadonly: false,
131
131
  formSize: 'sm',
132
- collapsible: undefined,
132
+ collapsible: false,
133
133
  collapsed: undefined,
134
134
  defaultCollapsed: false,
135
135
  collapsedWidth: '3rem',
@@ -157,10 +157,20 @@ const collapsedModel = computed({
157
157
  },
158
158
  })
159
159
 
160
- const isAnalysisVariant = computed(() => props.variant === 'analysis')
161
- const resolvedFloating = computed(() => props.floating ?? !isAnalysisVariant.value)
162
- const resolvedWidth = computed(() => props.width ?? (isAnalysisVariant.value ? '20rem' : '280px'))
163
- const resolvedCollapsible = computed(() => props.collapsible ?? isAnalysisVariant.value)
160
+ const resolvedDensity = computed(() =>
161
+ props.density ?? (props.dense === false ? 'comfortable' : 'compact'),
162
+ )
163
+
164
+ // Per-section open state; falls back to `defaultOpen` until the user toggles.
165
+ const openState = ref<Record<string, boolean>>({})
166
+
167
+ function isSectionOpen(section: SidebarToolSection): boolean {
168
+ return openState.value[section.id] ?? section.defaultOpen !== false
169
+ }
170
+
171
+ function toggleSection(section: SidebarToolSection) {
172
+ openState.value[section.id] = !isSectionOpen(section)
173
+ }
164
174
 
165
175
  const resolvedModel = computed<ControlModelBinding | undefined>(() => {
166
176
  return resolveControlModel(props.model)
@@ -224,16 +234,15 @@ const isVisible = computed<boolean>(() =>
224
234
  const sidebarClasses = computed<string[]>(() => [
225
235
  'mint-sidebar',
226
236
  `mint-sidebar--${props.side}`,
227
- `mint-sidebar--${props.variant}`,
228
- resolvedFloating.value ? 'mint-sidebar--floating' : 'mint-sidebar--static',
229
- props.dense ? 'mint-sidebar--dense' : '',
230
- resolvedCollapsible.value ? 'mint-sidebar--collapsible' : '',
237
+ props.floating ? 'mint-sidebar--floating' : 'mint-sidebar--static',
238
+ resolvedDensity.value === 'comfortable' ? 'mint-sidebar--comfortable' : '',
239
+ props.collapsible ? 'mint-sidebar--collapsible' : '',
231
240
  collapsedModel.value ? 'mint-sidebar--collapsed' : '',
232
241
  !isVisible.value ? 'mint-sidebar--hidden' : '',
233
242
  ])
234
243
 
235
244
  const sidebarStyle = computed<Record<string, string>>(() => ({
236
- width: collapsedModel.value ? props.collapsedWidth : resolvedWidth.value,
245
+ width: collapsedModel.value ? props.collapsedWidth : props.width,
237
246
  }))
238
247
 
239
248
  function handleFormUpdate(values: Record<string, unknown>) {
@@ -282,6 +291,17 @@ function badgeToneClass(section: SidebarToolSection): string {
282
291
  return `mint-sidebar__rail-badge--${section.badgeTone ?? 'cta'}`
283
292
  }
284
293
 
294
+ function tagToneClass(section: SidebarToolSection): string {
295
+ return `mint-sidebar__tag--${section.badgeTone ?? 'cta'}`
296
+ }
297
+
298
+ function actionClasses(action: SidebarToolSectionAction): string[] {
299
+ return [
300
+ 'mint-sidebar__group-action',
301
+ action.tone ? `mint-sidebar__group-action--${action.tone}` : '',
302
+ ]
303
+ }
304
+
285
305
  function mergeSidebarPanels(
286
306
  generated: Record<string, SidebarToolSection[]>,
287
307
  explicit: Record<string, SidebarToolSection[]>,
@@ -319,7 +339,7 @@ function firstVisibleViewId(panels: Record<string, SidebarToolSection[]>): strin
319
339
  >
320
340
  <!-- Header slot / built-in chrome -->
321
341
  <div
322
- v-if="$slots.header || title || subtitle || badge !== undefined || resolvedCollapsible"
342
+ v-if="$slots.header || title || subtitle || badge !== undefined || collapsible"
323
343
  class="mint-sidebar__header"
324
344
  >
325
345
  <slot
@@ -335,7 +355,7 @@ function firstVisibleViewId(panels: Record<string, SidebarToolSection[]>): strin
335
355
  <span v-if="badge !== undefined" class="mint-sidebar__badge">{{ badge }}</span>
336
356
  </div>
337
357
  <button
338
- v-if="resolvedCollapsible"
358
+ v-if="collapsible"
339
359
  type="button"
340
360
  class="mint-sidebar__collapse-button"
341
361
  :aria-label="collapsedModel ? expandButtonLabel : collapseButtonLabel"
@@ -365,41 +385,141 @@ function firstVisibleViewId(panels: Record<string, SidebarToolSection[]>): strin
365
385
  :id="contentId"
366
386
  class="mint-sidebar__sections"
367
387
  >
368
- <CollapsibleCard
388
+ <div
369
389
  v-for="section in activeSections"
370
390
  :key="section.id"
371
- :title="section.label"
372
- :subtitle="section.subtitle"
373
- :icon="section.icon"
374
- :icon-color="section.iconColor"
375
- :icon-bg="section.iconBg"
376
- :badge="section.badge"
377
- :badge-tone="section.badgeTone"
378
- :actions="section.actions"
379
- :dense="dense"
380
- :default-open="section.defaultOpen !== false"
381
- :show-toggle="section.showToggle"
382
- :toggle-value="toggleState[section.id] ?? false"
383
- @action="handleSectionAction(section.id, $event)"
384
- @update:toggle-value="emit('update:toggle', section.id, $event)"
391
+ :class="['mint-sidebar__group', isSectionOpen(section) ? 'mint-sidebar__group--open' : 'mint-sidebar__group--closed']"
385
392
  >
386
- <slot :name="`section-${section.id}`">
387
- <FormBuilder
388
- v-if="resolvedForms[section.id]"
389
- :model-value="resolvedValues"
390
- :schema="resolvedForms[section.id]"
391
- :enhancements="formEnhancements"
392
- :loading="formLoading"
393
- :disabled="formDisabled"
394
- :readonly="formReadonly"
395
- :size="formSize"
396
- :show-actions="showFormActions"
397
- @update:model-value="handleFormUpdate"
398
- @submit="handleFormSubmit(section.id, $event)"
399
- @cancel="handleFormCancel(section.id)"
400
- />
401
- </slot>
402
- </CollapsibleCard>
393
+ <div class="mint-sidebar__group-head">
394
+ <button
395
+ type="button"
396
+ class="mint-sidebar__group-toggle"
397
+ :aria-expanded="isSectionOpen(section)"
398
+ @click="toggleSection(section)"
399
+ >
400
+ <span
401
+ v-if="section.icon"
402
+ class="mint-sidebar__group-icon"
403
+ :style="railIconStyle(section)"
404
+ >
405
+ <svg
406
+ v-if="isSvgIcon(section.icon)"
407
+ class="mint-sidebar__group-svg"
408
+ viewBox="0 0 24 24"
409
+ fill="none"
410
+ stroke="currentColor"
411
+ stroke-width="2"
412
+ stroke-linecap="round"
413
+ stroke-linejoin="round"
414
+ aria-hidden="true"
415
+ >
416
+ <template v-if="Array.isArray(section.icon)">
417
+ <path v-for="(d, i) in section.icon" :key="i" :d="d" />
418
+ </template>
419
+ <path v-else :d="section.icon" />
420
+ </svg>
421
+ <span v-else class="mint-sidebar__group-text-icon">{{ section.icon }}</span>
422
+ </span>
423
+ <span class="mint-sidebar__group-copy">
424
+ <span class="mint-sidebar__group-label">{{ section.label }}</span>
425
+ <span v-if="section.subtitle" class="mint-sidebar__group-subtitle">{{ section.subtitle }}</span>
426
+ </span>
427
+ </button>
428
+
429
+ <div class="mint-sidebar__group-tail">
430
+ <span
431
+ v-if="section.badge !== undefined"
432
+ :class="['mint-sidebar__tag', tagToneClass(section)]"
433
+ >
434
+ {{ section.badge }}
435
+ </span>
436
+
437
+ <button
438
+ v-for="action in section.actions ?? []"
439
+ :key="action.id"
440
+ type="button"
441
+ :class="actionClasses(action)"
442
+ :style="action.iconColor ? { color: action.iconColor } : undefined"
443
+ :disabled="action.disabled"
444
+ :aria-label="action.label"
445
+ :title="action.label"
446
+ @click="handleSectionAction(section.id, action.id)"
447
+ >
448
+ <svg
449
+ v-if="action.icon && isSvgIcon(action.icon)"
450
+ class="mint-sidebar__group-action-icon"
451
+ viewBox="0 0 24 24"
452
+ fill="none"
453
+ stroke="currentColor"
454
+ stroke-width="2"
455
+ stroke-linecap="round"
456
+ stroke-linejoin="round"
457
+ aria-hidden="true"
458
+ >
459
+ <template v-if="Array.isArray(action.icon)">
460
+ <path v-for="(d, i) in action.icon" :key="i" :d="d" />
461
+ </template>
462
+ <path v-else :d="action.icon" />
463
+ </svg>
464
+ <span v-else-if="action.icon">{{ action.icon }}</span>
465
+ </button>
466
+
467
+ <button
468
+ v-if="section.showToggle"
469
+ type="button"
470
+ role="switch"
471
+ :aria-checked="toggleState[section.id] ?? false"
472
+ :aria-label="`Enable ${section.label}`"
473
+ :class="['mint-sidebar__switch', (toggleState[section.id] ?? false) ? 'mint-sidebar__switch--on' : '']"
474
+ @click="emit('update:toggle', section.id, !(toggleState[section.id] ?? false))"
475
+ >
476
+ <span class="mint-sidebar__switch-knob" />
477
+ </button>
478
+
479
+ <button
480
+ type="button"
481
+ class="mint-sidebar__chevron-button"
482
+ :aria-label="isSectionOpen(section) ? 'Collapse section' : 'Expand section'"
483
+ :aria-expanded="isSectionOpen(section)"
484
+ @click="toggleSection(section)"
485
+ >
486
+ <svg
487
+ :class="['mint-sidebar__chevron', isSectionOpen(section) ? 'mint-sidebar__chevron--open' : '']"
488
+ viewBox="0 0 24 24"
489
+ fill="none"
490
+ stroke="currentColor"
491
+ stroke-width="2"
492
+ stroke-linecap="round"
493
+ stroke-linejoin="round"
494
+ aria-hidden="true"
495
+ >
496
+ <path d="m6 9 6 6 6-6" />
497
+ </svg>
498
+ </button>
499
+ </div>
500
+ </div>
501
+
502
+ <Transition name="collapse">
503
+ <div v-show="isSectionOpen(section)" class="mint-sidebar__group-body">
504
+ <slot :name="`section-${section.id}`">
505
+ <FormBuilder
506
+ v-if="resolvedForms[section.id]"
507
+ :model-value="resolvedValues"
508
+ :schema="resolvedForms[section.id]"
509
+ :enhancements="formEnhancements"
510
+ :loading="formLoading"
511
+ :disabled="formDisabled"
512
+ :readonly="formReadonly"
513
+ :size="formSize"
514
+ :show-actions="showFormActions"
515
+ @update:model-value="handleFormUpdate"
516
+ @submit="handleFormSubmit(section.id, $event)"
517
+ @cancel="handleFormCancel(section.id)"
518
+ />
519
+ </slot>
520
+ </div>
521
+ </Transition>
522
+ </div>
403
523
 
404
524
  <slot
405
525
  :sections="activeSections"